Skip to content

Multi-Step Forms

Edit this page

Symfony provides support for building multi-step forms via the FormFlow system. A form flow splits a form into multiple steps while keeping a single form instance, shared data, validation groups, and navigation controls.

A form flow is implemented as a custom form type that defines:

  • the ordered list of steps
  • the storage strategy for the current step
  • the navigation behavior between steps
  • the persistence strategy for data between requests

Creating a Multi-Step Form

A form flow is created by extending AbstractFlowType and implementing buildFormFlow():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
use Symfony\Component\Form\Flow\AbstractFlowType;
use Symfony\Component\Form\Flow\FormFlowBuilderInterface;
use Symfony\Component\Form\Flow\Type\NavigatorFlowType;

class UserSignUpType extends AbstractFlowType
{
    public function buildFormFlow(FormFlowBuilderInterface $builder, array $options): void
    {
        $builder->addStep('personal', PersonalType::class);
        $builder->addStep('account', AccountType::class);
        $builder->addStep('confirmation', ConfirmationType::class);

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

Each step corresponds to a form type. Only the form of the current step is rendered and processed.

Note

Validation groups are automatically scoped to the current step. By default, the active groups are ['Default', '<current_step_name>'], which allows you to define step-specific constraints on your data object.

Storing the Current Step

A form flow needs to know which step is currently active. By default, this is done via a property path on the form data. Configure the step_property_path option in configureOptions():

1
2
3
4
5
6
7
8
9
use Symfony\Component\OptionsResolver\OptionsResolver;

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

The property is automatically updated when the user moves between steps.

Processing a Multi-Step Form

To use a form flow in a controller, create the form, handle the request, and check the flow state to determine what to do next:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// src/Controller/SignUpController.php
namespace App\Controller;

use App\Entity\User;
use App\Form\UserSignUpType;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

class SignUpController extends AbstractController
{
    #[Route('/signup', name: 'signup')]
    public function signup(Request $request): Response
    {
        $flow = $this->createForm(UserSignUpType::class, new User());
        $flow->handleRequest($request);

        if ($flow->isSubmitted() && $flow->isValid() && $flow->isFinished()) {
            // all steps are completed; process the final data
            $data = $flow->getData();

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

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

After calling handleRequest(), the flow knows which button was clicked (next, previous, finish, or reset) and acts accordingly.

Warning

getStepForm() both transitions the flow to the next step and returns the form for that step. Call it only once per request, and only after all state checks (isSubmitted(), isValid(), isFinished()) are complete. Calling it multiple times will advance the step counter more than once.

When isFinished() returns true, the data object returned by getData() contains all accumulated data from every step.

Step Ordering and Priority

Steps are ordered by priority (higher first). If no priority is defined, steps are ordered by insertion order:

1
2
$builder->addStep('first', FirstType::class, priority: 10);
$builder->addStep('second', SecondType::class);

Skipping Steps

Steps can be conditionally skipped using a callable:

1
2
3
4
5
$builder->addStep(
    'professional',
    ProfessionalType::class,
    skip: fn (User $data) => !$data->isWorker()
);

Skipped steps are not rendered and are ignored during navigation.

Persisting Data Between Requests

Form data is persisted between requests using a data storage. Symfony provides several implementations:

  • SessionDataStorage: stores data in the session (used by default)
  • InMemoryDataStorage: stores data in memory (mainly for tests)
  • NullDataStorage: does not persist data

Note

SessionDataStorage requires an active HTTP session.

To configure a data storage explicitly:

1
2
3
4
5
use Symfony\Component\Form\Flow\DataStorage\SessionDataStorage;

$resolver->setDefaults([
    'data_storage' => new SessionDataStorage('signup_flow', $requestStack),
]);

Multi-Step Form Navigation

Navigation is handled via special submit button types:

  • PreviousFlowType: goes back to the previous step
  • NextFlowType: advances to the next step, validating the current one
  • FinishFlowType: submits the final step and marks the flow as finished
  • ResetFlowType: clears all stored data and returns to the first step

Symfony provides a default navigator that includes previous, next, and finish buttons:

use SymfonyComponentFormFlowTypeNavigatorFlowType;

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

Buttons are automatically shown or hidden depending on the current step. To also include a reset button, use the with_reset option:

$builder->add('navigator', NavigatorFlowType::class, [
'with_reset' => true,

]);

Custom Navigation Buttons

You can define custom navigation buttons independently of the default navigator:

1
2
3
4
5
6
7
8
use Symfony\Component\Form\Flow\Type\NextFlowType;

$builder->add('skip', NextFlowType::class, [
    'clear_submission' => true,
    'validate' => false,
    'validation_groups' => [],
    'include_if' => ['professional'],
]);

Each button type accepts options that control validation behavior, which steps it appears on (include_if), and whether the current submission data is cleared (clear_submission).

Finishing and Resetting the Flow

When the finish button is clicked, the flow is marked as finished, the data storage is cleared (if auto_reset is enabled), and the cursor is reset to the first step. Use $form->isFinished() to check whether the flow has completed.

Rendering Navigation Information

The form view exposes step metadata that can be used to build progress indicators or navigation menus:

1
2
3
4
5
6
7
8
9
10
<ol>
    {% for step in form.vars.visible_steps %}
        <li class="{{ step.is_current_step ? 'active' : '' }}">
            {{ step.name }}
        </li>
    {% endfor %}
</ol>

{# current step index, zero-based #}
{{ form.vars.cursor }}

Each step object exposes the following properties:

  • name
  • index
  • position
  • is_current_step
  • is_skipped
  • can_be_skipped

Using Multi-Step Forms with Turbo

When Symfony UX Turbo is enabled, Turbo Drive intercepts form submissions via AJAX and relies on HTTP status codes to decide how to handle the response. Without returning the correct codes, Turbo will receive the response but will not render the new step.

The expected status codes are:

  • 200: initial rendering (form not yet submitted)
  • 422: form submitted but invalid (Turbo re-renders the form in place)
  • 303: form submitted and valid, advancing to the next step (Turbo follows the redirect)

The following example shows a controller that handles all three cases:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// src/Controller/SignUpController.php
namespace App\Controller;

use App\Entity\User;
use App\Form\UserSignUpType;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

class SignUpController extends AbstractController
{
    #[Route('/signup', name: 'signup')]
    public function signup(Request $request): Response
    {
        $flow = $this->createForm(UserSignUpType::class, new User());
        $flow->handleRequest($request);

        if ($flow->isSubmitted() && $flow->isValid() && $flow->isFinished()) {
            // all steps are completed; process the final data
            $data = $flow->getData();

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

        // At this point, the flow is either not yet submitted,
        // submitted but invalid, or submitted, valid, and not yet finished.
        $statusCode = match (true) {
            !$flow->isSubmitted() => Response::HTTP_OK,
            !$flow->isValid() => Response::HTTP_UNPROCESSABLE_ENTITY,
            default => Response::HTTP_SEE_OTHER,
        };

        return $this->render('signup.html.twig', [
            'form' => $flow->getStepForm(),
        ], new Response(status: $statusCode));
    }
}

Known Limitations

  • Nested form flows are not supported; a flow step cannot itself be a flow.
  • One step per request: only the active step's form is processed on each request. Submitting multiple steps in a single request is not possible.
  • Steps must be declared explicitly in buildFormFlow(). Dynamic or runtime-generated steps are not supported.
This work, including the code samples, is licensed under a Creative Commons BY-SA 3.0 license.
TOC
    Version