FormFlow is here!
Symfony 7.4 landed at the end of November with its share of new features. Among them, a really interesting evolution of the Form component: FormFlow.
FormFlow lets you split large forms into several steps, easily handle navigation between them, and control validation step by step.
In this article, we'll see how to use it through a concrete example: a mobile subscription flow.
Subscribing to a mobile plan
Let's pick up our example.
To subscribe to a mobile plan, you usually go through several steps (we're simplifying on purpose, it's just for the example):
- Plan selection
- List of available plans
- Choice between SIM card or eSIM
- Bank details
- Account holder name
- IBAN
- SEPA agreement
- Personal info
- Last name
- First name
- Address
- Postal code
- City
Each of these steps will be a FormType
1. Creating our Models
We'll need several Models:
- A model for the plan
<?php
declare(strict_types=1);
namespace App\Form\Data\Step;
use Symfony\Component\Validator\Constraints as Assert;
class Offer
{
public function __construct(
#[Assert\NotBlank(groups: ['offer'])]
public ?string $name = null,
public bool $eSim = false
) {
}
}
- One for the bank details
<?php
declare(strict_types=1);
namespace App\Form\Data\Step;
use Symfony\Component\Validator\Constraints as Assert;
class BankingInformation
{
public function __construct(
#[Assert\NotBlank(groups: ['banking'])]
public ?string $owner = null,
#[Assert\NotBlank(groups: ['banking'])]
public ?string $iban = null,
public bool $sepaAgreement = false
) {
}
}
- The one for personal info
<?php
declare(strict_types=1);
namespace App\Form\Data\Step;
use Symfony\Component\Validator\Constraints as Assert;
class Personal
{
public function __construct(
#[Assert\NotBlank(groups: ['personal'])]
public ?string $firstName = null,
#[Assert\NotBlank(groups: ['personal'])]
public ?string $lastName = null,
#[Assert\Email(groups: ['personal'])]
public ?string $email = null,
#[Assert\NotBlank(groups: ['personal'])]
public ?string $phone = null,
#[Assert\NotBlank(groups: ['personal'])]
public ?string $address = null,
#[Assert\NotBlank(groups: ['personal'])]
public ?string $zipCode = null,
#[Assert\NotBlank(groups: ['personal'])]
public ?string $city = null
) {
}
}
- And finally, our global model
<?php
declare(strict_types=1);
namespace App\Form\Data;
use App\Form\Data\Step\BankingInformation;
use App\Form\Data\Step\Offer;
use App\Form\Data\Step\Personal;
use Symfony\Component\Validator\Constraints as Assert;
class Subscription
{
public function __construct(
#[Assert\Valid(groups: ['offer'])]
public Offer $offer = new Offer(),
#[Assert\Valid(groups: ['banking'])]
public BankingInformation $banking = new BankingInformation(),
#[Assert\Valid(groups: ['personal'])]
public Personal $personal = new Personal(),
public string $currentStep = 'offer'
) {
}
}
Note the public string $currentStep= 'offer' which lets us know the current step of our form — set it to your first step's value by default.
2- Creating the steps
Now that our models are ready, we can tackle the different form steps.
Each step will be represented by a classic FormType: in our case, OfferType, BankingType and PersonalType.
Example for the plan:
<?php
declare(strict_types=1);
namespace App\Form\Type\Step;
use App\Form\Data\Step\Offer;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class OfferType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('name', ChoiceType::class, [
'choices' => [
'Unlimited everything France and Europe - €25.99/month' => 'allin',
'Unlimited SMS and calls 100GB - €19.99/month' => 'smscall100go',
'Unlimited SMS and calls 50 - €10.99/month' => 'smscall50go',
'Unlimited SMS 2h of calls - €2.99/month' => 'sms2call',
],
'required' => true
]);
$builder->add('eSim', CheckboxType::class, ['required' => false]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'label' => false,
'help' => 'Your plan',
'data_class' => Offer::class
]);
}
}
We'll do the same for the other types.
Finally, our "main" Type which will extend AbstractFlowType
<?php
declare(strict_types=1);
namespace App\Form\Type;
use App\Form\Data\Subscription;
use App\Form\Type\Step\BankingType;
use App\Form\Type\Step\OfferType;
use App\Form\Type\Step\PersonalType;
use Symfony\Component\Form\Flow\AbstractFlowType;
use Symfony\Component\Form\Flow\FormFlowBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class SubscriptionType extends AbstractFlowType
{
public function buildFormFlow(FormFlowBuilderInterface $builder, array $options): void
{
$builder
->addStep('offer', OfferType::class)
->addStep('banking', BankingType::class)
->addStep('personal', PersonalType::class)
->add('navigator', SubscriptionNavigatorType::class);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Subscription::class,
'step_property_path' => 'currentStep'
]);
}
}
A few explanations before we continue:
- each step is added to the form via
addStep()which takes the step name and the associated Type. You can also add askipparameter to define a rule for skipping a step, via an anonymous function. For example, if we had a free plan in the Offer step, we might want to skip the IBAN step. That would look something like this:
public function buildFormFlow(FormFlowBuilderInterface $builder, array $options): void
{
$builder
->addStep('offer', OfferType::class)
->addStep('banking', BankingType::class, skip: fn (Subscription $data) => $data->offer->name === 'free')
->addStep('personal', PersonalType::class)
->add('navigator', SubscriptionNavigatorTy::class);
}
$data corresponds to a Subscription with the data already entered.
step_property_pathtells FormFlow where to store/read the current step in our Subscription object. Here, we point it to the currentStep property we added earlier.->add('navigator', NavigatorFlowType::class);adds the default navigator to move between steps. You can also create your own, adding your own buttons (like a form reset button) or your own rules forpreviousornextfor example. To handle these interactions, you can use the new Types:ResetFlowTypeto reset the formNextFlowTypeto go to the next stepPreviousFlowType: to go to the previous stepFinishFlowTypefinishes and resets the form
Each of these types is customizable, and you can control their visibility with include_if.
$builder->add('back_to', PreviousFlowType::class, [
'validate' => false,
'validation_groups' => false,
'clear_submission' => false,
'include_if' => fn (FormFlowCursor $cursor) => !$cursor->isFirstStep(),
]);
We'll add our own Navigator
<?php
declare(strict_types=1);
namespace App\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Flow\FormFlowCursor;
use Symfony\Component\Form\Flow\Type\FinishFlowType;
use Symfony\Component\Form\Flow\Type\NextFlowType;
use Symfony\Component\Form\Flow\Type\PreviousFlowType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class SubscriptionNavigatorType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('previous', PreviousFlowType::class, [
'label' => 'Previous'
]);
$builder->add('next', NextFlowType::class, [
'include_if' => fn(FormFlowCursor $cursor) => !$cursor->isLastStep(),
'label' => 'Next'
]);
$builder->add('finish', FinishFlowType::class, ['label' => 'Subscribe']);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'label' => false,
'mapped' => false,
'priority' => -100
]);
}
}
Here, we disable step validation when going back, and we don't want Next on the last step, nor Previous on the first one (these cases are handled natively, this is just for the example)
3- Rendering our form
a- Controller side
Creating the form in the Controller is pretty close to what we already know
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Form\Data\Subscription;
use App\Form\Type\SubscriptionType;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class SubscriptionController extends AbstractController
{
#[Route(path: '/subscription', name: 'subscription')]
public function __invoke(Request $request)
{
$flow = $this
->createForm(SubscriptionType::class, new Subscription())
->handleRequest($request);
if ($flow->isSubmitted() && $flow->isValid() && $flow->isFinished()) {
$data = $flow->getData();
// Your processing (sending email, saving to DB, etc...
$this->addFlash('success', 'Thanks for your subscription!');
return $this->redirectToRoute('subscription', [], Response::HTTP_SEE_OTHER);
}
return $this->render('subscription.html.twig', [
'form' => $flow->getStepForm(),
], new Response(status: 303));
}
}
- `createForm works like any classic form
- in addition to
isSubmitted()andisValid(), we haveisFinished()to know if the full flow is done (last step reached) getStepForm()lets you retrieve only the form for the current step- the 303 isn't required, but with Turbo it avoids the
Form responses must redirect to another locationerror you sometimes run into.
b- Twig rendering
On the Twig side, rendering is simple
{% extends 'base.html.twig' %}
{% block body %}
<div class="subscription-container">
<h1>Your new mobile plan</h1>
<div class="step-indicator">
{% set total_steps = 3 %}
{% set current_step = form.vars.cursor.currentstep %}
{% for i in 1..total_steps %}
<div class="step {{ i == current_step ? 'active' : (i < current_step ? 'completed' : '') }}">
<span class="step-number">{{ i }}</span>
<span class="step-label">
{% if i == 1 %}Plan{% elseif i == 2 %}Payment{% elseif i == 3 %}Info{% endif %}
</span>
</div>
{% endfor %}
</div>
<div class="form-wrapper">
{{ form_start(form, {'attr': {'class': 'styled-form'}}) }}
{{ form_errors(form) }}
<div class="form-content">
{% for child in form.children %}
{% if child.vars.name != 'navigator' %}
<div class="form-step-fields">
{{ form_row(child) }}
</div>
{% endif %}
{% endfor %}
</div>
<div class="form-navigation">
{{ form_widget(form.navigator) }}
</div>
{{ form_end(form) }}
</div>
</div>
{% endblock %}
The rendering is raw and basic, but the navigation works.
Resources
You now have an overview of what you can do with FormFlow, but this is only the beginning:
- ability to add sub-steps
- full customization of the rendering
- using
FormFlowCursorto trigger actions at each step (progressive saving, conditional processing, etc.)
