backFebruary 25, 2026

Multi-step Forms with Symfony 8 FormFlow

Symfony 7.4 and 8.0 added native multi-step form support through a feature called FormFlow. Previously, you'd reach for a bundle like CraueFormFlowBundle. Now it's part of the form component, and the API fits right in with everything else you already know.

This post walks through a check-in flow with three steps: passenger info, baggage, and boarding preferences.

The data class

All data lives in a single class. Each sub-object maps to a step, and a currentStep string tracks the active position in the flow.

use Symfony\Component\Validator\Constraints as Assert;

class CheckIn
{
    public function __construct(
        #[Assert\Valid(groups: ['passenger'])]
        public Passenger $passenger = new Passenger(),

        #[Assert\Valid(groups: ['baggage'])]
        public Baggage $baggage = new Baggage(),

        #[Assert\Valid(groups: ['boarding'])]
        public Boarding $boarding = new Boarding(),

        public string $currentStep = 'passenger',
    ) {}
}

The validation groups match the step names. When the passenger step is active, only constraints in the passenger group run. The other steps are left alone until the user gets there.

Step types

Each step is a regular form type. Nothing special here.

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class PassengerType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('firstName')
            ->add('lastName')
            ->add('email', EmailType::class)
            ->add('passportNumber');
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults(['data_class' => Passenger::class]);
    }
}

Define a type like this for each step. They have no knowledge of the flow.

The flow type

The flow type extends AbstractFlowType and uses buildFormFlow() instead of buildForm().

use Symfony\Component\Form\Flow\AbstractFlowType;
use Symfony\Component\Form\Flow\FormFlowBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class CheckInType extends AbstractFlowType
{
    public function buildFormFlow(FormFlowBuilderInterface $builder, array $options): void
    {
        $builder->addStep('passenger', PassengerType::class);
        $builder->addStep('baggage', BaggageType::class);
        $builder->addStep('boarding', BoardingType::class);
        $builder->add('navigator', NavigatorFlowType::class);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => CheckIn::class,
            'step_property_path' => 'currentStep',
        ]);
    }
}

step_property_path tells the flow which property on your data class holds the current step name. NavigatorFlowType renders Previous, Next, and Finish buttons automatically based on where the user is in the flow.

The controller

The controller looks almost identical to a regular Symfony form controller.

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

class CheckInController extends AbstractController
{
    #[Route('/check-in', name: 'app_check_in')]
    public function __invoke(Request $request): Response
    {
        $flow = $this->createForm(CheckInType::class, new CheckIn())
            ->handleRequest($request);

        if ($flow->isSubmitted() && $flow->isValid() && $flow->isFinished()) {
            $data = $flow->getData();
            // persist or process $data

            return $this->redirectToRoute('app_check_in_success');
        }

        return $this->render('check_in/index.html.twig', [
            'form' => $flow->getStepForm(),
        ]);
    }
}

Two things differ from a regular form handler. isFinished() returns true only after the user completes the last step. getStepForm() returns the form for the current step only, which is what you pass to the template.

The template

{{ form(form) }}

That's enough for the basic case. The navigator buttons handle all step transitions.

Skipping steps conditionally

Some steps may not apply to every user. You can skip them with a closure that receives your data object.

$builder->addStep('baggage', BaggageType::class, [
    'skip' => fn (CheckIn $data) => !$data->passenger->hasBaggage,
]);

The closure runs each time the flow evaluates which steps are active, so it has access to everything collected in previous steps.

Custom navigator

The default NavigatorFlowType works for most cases, but you can define your own using the individual button types.

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;

class CheckInNavigatorType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('back', PreviousFlowType::class, ['label' => 'Back'])
            ->add('next', NextFlowType::class, ['label' => 'Continue'])
            ->add('finish', FinishFlowType::class, ['label' => 'Complete Check-in']);
    }
}

Then use it in the flow type instead of NavigatorFlowType:

$builder->add('navigator', CheckInNavigatorType::class);

Buttons that don't validate

For actions like adding an item to a collection within a step, you can add a button that skips validation entirely.

$builder->add('addItem', ButtonFlowType::class, [
    'validate' => false,
    'handler' => function (Baggage $data): void {
        $data->items[] = new BaggageItem();
    },
]);

The handler runs when the button is clicked. You can also type-hint FormFlowInterface as a second parameter if you need access to the full flow from within the handler.

For more examples including tab-based navigation, file uploads within steps, and database-backed state storage, see the official demo application.