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 a skip parameter 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_path tells 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 for previous or next for example. To handle these interactions, you can use the new Types:
    • ResetFlowType to reset the form
    • NextFlowType to go to the next step
    • PreviousFlowType: to go to the previous step
    • FinishFlowType finishes 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() and isValid(), we have isFinished() 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 location error 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 FormFlowCursor to trigger actions at each step (progressive saving, conditional processing, etc.)