Skip to content

Conversation

@silasjoisten
Copy link
Contributor

@silasjoisten silasjoisten commented Jan 18, 2025

Q A
Branch? 7.3
Bug fix? no
New feature? yes
Deprecations? no
Issues -
License MIT

This PR introduces the MultiStepType form type, which allows for the creation of form flows. The idea for this form type was initially proposed in a PR for Symfony UX, where it was suggested that the type would be better suited within the Symfony Form component.

But this form works also without Symfony UX.

Usage:

declare(strict_types=1);

namespace App\Form;

use Symfony\Component\Form\Extension\Core\Type\MultiStepType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\IsTrue;
use Symfony\Component\Validator\Constraints\NotBlank;
use App\Form\AuthorType;

final class MyFancyWizardType extends AbstractType
{
    /**
     * @return class-string<AbstractType>
     */
    public function getParent(): string
    {
        return MultiStepType::class;
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'steps' => [
                'general' => static function (FormBuilderInterface $builder, array $options): void {
                    $builder
                        ->add('age', NumberType::class, [
                            'label' => 'Age',
                            'constraints' => [
                                new NotBlank(),
                            ],
                        ]);

                    if ($options['customFlag'] === 'yes') {
                        $builder->add('name', TextType::class, [
                            'label' => 'Name',
                            'constraints' => [
                                new NotBlank(),
                            ],
                        ]);
                    } else {
                        $builder->add('notname', TextType::class, [
                            'label' => 'Not Name',
                            'constraints' => [
                                new NotBlank(),
                            ],
                        ]);
                    }
                },
                'contact' => static function (FormBuilderInterface $builder): void {
                    $builder
                        ->add('email', TextType::class, [
                            'label' => 'E-Mail',
                            'constraints' => [
                                new NotBlank(),
                            ],
                        ])
                        ->add('newsletter', CheckboxType::class, [
                            'label' => 'Newsletter',
                            'constraints' => [
                                new IsTrue(),
                            ],
                        ]);
                },
                'author' => AuthorType::class,
            ],
        ]);
    }
}

In the controller (this is only one possible usage of the form in order to persist the current step and the data):

<?php

declare(strict_types=1);

namespace App\Controller;

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


final class HelloFormController extends AbstractController
{
    #[Route(path: '/hello-form', name: 'hello_form', methods: ['GET', 'POST'])]
    public function index(Request $request): Response
    {
        $session = $request->getSession();
        $currentStep = $session->get('current_step', 'general') ?? 'general';

        $form = $this->createForm(MyFancyMultiStepType::class, $session->get($currentStep, []), [
            'current_step' => $currentStep,
        ]);

        $session->set('current_step', $form->getConfig()->getOption('current_step'));
        $form->handleRequest($request);

        if ($form->isSubmitted()) {
            if ($form->has('back') && $form->get('back')->isClicked()) {
                $session->set('current_step', $form->getConfig()->getOption('previous_step'));

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

            if ($form->get('submit')->isClicked() && $form->isValid()) {
                $session->set($currentStep, $form->getData());
                $session->set('current_step', $form->getConfig()->getOption('next_step'));

                if (null !== $form->getConfig()->getOption('next_step')) {
                    return $this->redirectToRoute('hello_form');
                }

                $data = [];

                foreach (\array_keys($form->getConfig()->getOption('steps')) as $name) {
                    $data[$name] = $session->get($name, []);
                    $session->remove($name);
                }

                dump($data);

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

        return $this->render('hello_form/template.html.twig', [
            'form' => $form->createView(),
        ]);
    }
}

And Rendering:

<div class="max-w-6xl mx-auto">
    <div class="my-12">
        {% for step in form.vars.steps %}
            <span class="{{ html_classes({'font-bold': step == form.vars.current_step }) }}">{{ step }}</span> {% if not loop.last %} - {% endif %}
        {% endfor %}
    </div>

    {{ form_start(form) }}

    {{ form_end(form) }}


    <div class="mt-12">
        {{ form.vars.current_step_number }} / {{ form.vars.total_steps_count }}
    </div>
</div>

Look and feel!

CleanShot 2025-01-19 at 14 44 00

@silasjoisten silasjoisten changed the title Enhancement: Adds MultiStepType [Form] Enhancement: Adds MultiStepType Jan 18, 2025
@silasjoisten silasjoisten changed the title [Form] Enhancement: Adds MultiStepType [Form] Add MultiStepType Jan 18, 2025
Copy link
Member

@yceruto yceruto left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting idea!

I wonder if we could create a default theme for this form type that would (at least):

  • render the current step form (this is already the case but might require some tweaks for the next point below)
  • render the back/next "submit" buttons based on the "step number" (which is currently missing but can be calculated easily using the provided options)

While data storage and form handling are beyond the scope of this type, I believe having that default theme would be great.

@carsonbot carsonbot changed the title [Form] Add MultiStepType Add MultiStepType Jan 18, 2025
@silasjoisten
Copy link
Contributor Author

silasjoisten commented Jan 18, 2025

Hey thanks for the review!

render the back/next "submit" buttons based on the "step number" (which is currently missing but can be calculated easily using the provided options)

I tried today to find a solution which is nice but i did not succeed. If we have a next and a previous button there would be also the need of disabling the previous button when the current step number is 0 and maybe change the label of the next button when the current step number is the last step. All this requires some sort of wrapper class including a storage. (Which will be handled in UX) if you got any idea of how to achieve that i'd very happy!

I added some more helpers in view vars which makes rendering easier and also i added some options in order to allow navigate through steps. I also updated this PR description with an example of how to use the form in a controller.

@silasjoisten silasjoisten requested a review from yceruto January 19, 2025 09:38
@yceruto
Copy link
Member

yceruto commented Jan 19, 2025

After reviewing the PR description again following your latest update, it might be helpful to include an example with a DTO (+ validator constraints bound to the underlying object) as this aligns with the recommended approach for working with forms.

I like the idea and how we can configure the steps, but handling it from the controller still feels a bit complex IMO.

@silasjoisten
Copy link
Contributor Author

After reviewing the PR description again following your latest update, it might be helpful to include an example with a DTO (+ validator constraints bound to the underlying object) as this aligns with the recommended approach for working with forms.

Sure i can add that. Thats just gonna be a fee lines of code.

I like the idea and how we can configure the steps, but handling it from the controller still feels a bit complex IMO.

Well but all of that is implementation detail. Like which storage to use which steps to skip or add to your multistep type. Without an implementation of a storage its not possible to enhance the DX. Mainly we should not forget that the main reason for this PR is to have this available for a nice LiveComponent in symfony UX.

If you have any better idea of how to increase the developer experience feel free to tell. I honestly ran out of ideas because i tried a lot of different things today. And i always came to the same conclusion... storage, storage, storage 😅

@smnandre
Copy link
Member

Would this require a lot of work to implement the following (not in this PR of course)

  • serialize / encode the data from previous steps
  • sign it
  • add a hidden field / textarea

Or is this "one" of the storages we're talking about ? Will there be any interface for the "storage" in symfony/form .. or we let entirely people implementing from scratch this part ?

@PReimers
Copy link
Contributor

PReimers commented Jan 19, 2025

Will there be any interface for the "storage" in symfony/form .. or we let entirely people implementing from scratch this part ?

We "started" with a storage imterface (and a session storage) when we build it for UX.
https://github.com/symfony/ux/blob/4c6a06576d30caf1b96a0ba2a66d0f896f767edb/src/LiveComponent/src/Storage/StorageInterface.php
https://github.com/symfony/ux/blob/4c6a06576d30caf1b96a0ba2a66d0f896f767edb/src/LiveComponent/src/Storage/SessionStorage.php

But in the meantime we figured Symfony has no place for a storage interface (except if we would create something like a storage-contract. But a storage-contract would only provide one interface, implementation like a SessionStorage/FilesystemStorage/RedisStorage/... would need to go into different components (SessionStorage in Framework? FilesystemStore in Filesystem? RedisStorage in ...)

Or are we open to add "storage" into Form?

@yceruto
Copy link
Member

yceruto commented Jan 19, 2025

Without an implementation of a storage its not possible to enhance the DX

What about adding a new FormFlow utility + a new DataStorageInterface to implement that abstraction?

Mainly we should not forget that the main reason for this PR is to have this available for a nice LiveComponent in symfony UX.

I think you raise an important point here. Introducing this type in the Symfony repo would mean designing it in a way that isn't strictly tied to the UX package, as not all users may be using UX packages like we do. It's essential to ensure that it works well for everyone.

@PReimers
Copy link
Contributor

Would this require a lot of work to implement the following (not in this PR of course)

* serialize / encode the data from previous steps
* sign it
* add a hidden field / textarea

We were thinking about implementing a hidden field containing the current state, but when the page is reloaded we'll lose the current state.

In our current example implementaion we're using the session, this is persistent across reloads (until the session is killed).
Would we need to serialize/deserialize and/or sign the data in that case?

@silasjoisten
Copy link
Contributor Author

What about adding a new FormFlow utility + a new DataStorageInterface to implement that abstraction?

Yes we could do that. As we already have the Interface. How do we do the implementation? Shall i require http foundation for the session?

I think you raise an important point here. Introducing this type in the Symfony repo would mean designing it in a way that isn't strictly tied to the UX package, as not all users may be using UX packages like we do. It's essential to ensure that it works well for everyone.

I agree on that. But isn't it usable without symfony ux yet? I mean it might be a bit more code to write but it would be already usable.

Regarding the storage where should i put it? Can you maybe help me on this one?

@smnandre
Copy link
Member

I would not be shocked if a "FormStepPersisterInterface" or "Loader" or something like this was added into Form 🤷

Like ChoiceLoaderInterface maybe ?

Or simply document the events and to plug itself to store the data.

We also can consider there is only one entity saved, partially at each step, and then nothing is really needed here ?

@smnandre
Copy link
Member

In our current example implementaion we're using the session, this is persistent across reloads (until the session is killed).
Would we need to serialize/deserialize and/or sign the data in that case?

I guess not, indeed! 👍

(documentation will maybe just need a warning regarding funnel forms with login or register in the way, as session is often reset then)

@yceruto
Copy link
Member

yceruto commented Jan 19, 2025

Yes we could do that. As we already have the Interface. How do we do the implementation? Shall i require http foundation for the session?

We shouldn't require the http foundation directly, take a look at RequestHandlerInterface mechanism (and their subclasses).

Regarding the storage where should i put it? Can you maybe help me on this one?

Let me play a bit with this proposal and I'll back next week with more details about it.

@silasjoisten
Copy link
Contributor Author

We also can consider there is only one entity saved, partially at each step, and then nothing is really needed here ?

If you concider that yes then the implementation would be Userland

@yceruto
Copy link
Member

yceruto commented Jan 20, 2025

For reference https://github.com/craue/CraueFormFlowBundle. I've used this bundle in several projects and found it suitable for most advanced cases. It's worth a look and a ping to @craue who has expertise on this topic.

@stof
Copy link
Member

stof commented Jan 20, 2025

@silasjoisten I suggest updating your example code to remove the usage of OskarStark\Symfony\Http\Responder. An example using core APIs is more useful as the community will be familiar with them (and will help the documentation team)

@silasjoisten
Copy link
Contributor Author

silasjoisten commented Jan 21, 2025

For reference https://github.com/craue/CraueFormFlowBundle. I've used this bundle in several projects and found it suitable for most advanced cases. It's worth a look and a ping to @craue who has expertise on this topic.

Yea i have worked with it as well and i did not like the DX in it. It felt quite old and i mean its a common problem why shouldn't it be part of Symfony itself. Even if its just a simple form flow without skipping things.

For some cases yes you need a more complex form flow. but sometimes you want to have it in order to have a nice User experience in you Application.

@PReimers
Copy link
Contributor

PReimers commented Feb 8, 2025

What is the current state of this PR?
Do we need to do something, or are we waiting for something?

@yceruto
Copy link
Member

yceruto commented Feb 8, 2025

I tested the proposal as it is, and it feels like too much responsibility for the user to handle (in the current state). I'm not referring to the form step definition, which is already simple, but to the navigation and data storage part across steps. IMO, there should be a default implementation that handles that for us, not a UX but a Symfony one, flexible enough to be used with or without UX capabilities.

This is my current expectation for this feature (https://gist.github.com/yceruto/0fe65c8669016fe48f24c4e047ce7fb1):

  • it should work with a DTO (including the current step property)

  • the step validation should follow the validation_groups approach with constraints defined in the DTO, e.g.

    $resolver->setDefault('validation_groups', function (Options $options) {
        return ['Default', $options['current_step']];
    });
  • default navigation buttons (back, next, submit) and an action-based navigation handling approach (rather than checking by the button name). For example, I might add a new "skip" button with a "next" action in one step, which skips validations.

  • a customizable data storage strategy (probably through a form option) to persist the data between steps. The session strategy should be the default.

my two cents :) still looking forward to this feature 💘

@silasjoisten
Copy link
Contributor Author

Nicee gist you made there! I focus ghis weekend on this Pr. And try to make it as you expect :) i like your idea very much!

if (\is_callable($currentStep)) {
$currentStep($builder, $options);
} elseif (\is_string($currentStep)) {
$builder->add($options['current_step'], $currentStep);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the step type may require specific options that differ from those in the root form.

we need to consider this, as it's currently a limitation

@94noni
Copy link
Contributor

94noni commented Feb 16, 2025

Comming from the initial UX repo PR :) nice one

If something like this land on sf/form I think it needs storage included otherwise it may be « hard/prone to error » to implement/handle and may lead to devland issues no?

});

$resolver->setDefaults([
'hide_back_button_on_first_step' => false,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

display_ with default true instead of hide_ default false?
Seems easier to read the positive way not the négative

Copy link
Contributor

@Spomky Spomky left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Many thanks for this PR.
I find the idea really very interesting. This type of need is common in applications.
However I have some questions/remarks.

return false;
}

if ((!\is_string($step) || !is_subclass_of($step, AbstractType::class)) && !\is_callable($step)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A $step object that implements FormTypeInterface should be allowed as well

Also, I have the feeling that parenthesis should be added here (mixing || and &&).

$builder->add($options['current_step'], $currentStep);
}

$builder->add('back', SubmitType::class, [
Copy link
Contributor

@Spomky Spomky Mar 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not a fan of buttons within form type objects. I prefer their integration into the template.
There should be a way for the template to know when each button has to be displayed or not e.g. with the help of the isFirstStep or isLastStep methods.
Also, this will remove the need of the hide_back_button_on_first_step, button_back_options, button_next_options and button_submit_options options. WDYT?

@yceruto
Copy link
Member

yceruto commented Apr 9, 2025

Hi there! I'll get back to this topic next week with a complete alternative proposal that addresses the main implementation concerns (this one: #59548 (comment) and others mentioned).

I can't wait to share it with all of you!

@yceruto
Copy link
Member

yceruto commented Apr 13, 2025

Here we go #60212 !

@silasjoisten
Copy link
Contributor Author

I will close this one thank you @yceruto

@silasjoisten silasjoisten deleted the feature/multi-step-type branch April 14, 2025 19:57
nicolas-grekas added a commit that referenced this pull request Oct 24, 2025
…yceruto)

This PR was merged into the 7.4 branch.

Discussion
----------

[Form] Add `FormFlow` for multistep forms management

| Q             | A
| ------------- | ---
| Branch?       | 7.4
| Bug fix?      | no
| New feature?  | yes
| Deprecations? | no
| Issues        | -
| License       | MIT

Alternative to
* #59548

Inspired on `@silasjoisten`'s work and `@craue`'s [CraueFormFlowBundle](https://github.com/craue/CraueFormFlowBundle), thank you!

# FormFlow

This PR introduces `FormFlow`, a kind of super component built on top of the existing `Form` architecture. It handles the definition, creation, and handling of multistep forms, including data management, submit buttons, and validations across steps.

![formflow](https://github.com/user-attachments/assets/8a60a447-e43e-4ea5-8d77-2351522ac95a)

Demo app: https://github.com/yceruto/formflow-demo
Slides: https://speakerdeck.com/yceruto/formflow-build-stunning-multistep-forms

## `AbstractFlowType`

Just like `AbstractType` defines a single form based on the `FormType`, `AbstractFlowType` can be used to define a multistep form based on `FormFlowType`.

```php
class UserSignUpType extends AbstractFlowType
{
    public function buildFormFlow(FormFlowBuilderInterface $builder, array $options): void
    {
        $builder->addStep('personal', UserSignUpPersonalType::class);
        $builder->addStep('professional', UserSignUpProfessionalType::class);
        $builder->addStep('account', UserSignUpAccountType::class);

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

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

The step name comes from the first param of `addStep()`, which matches the form name, like this:
 * The `personal` form of type `UserSignUpPersonalType` will be the step `personal`,
 * The `professional` form of type `UserSignUpProfessionalType` will be the step `professional`,
 * and so on.

When the form is created, the `currentStep` value determines which step form to build, only the matching one, from the steps defined above, will be built.

## Controller

Use the existent `createForm()` in your controller to create a `FormFlow` instance.

```php
class UserSignUpController extends AbstractController
{
    #[Route('/signup')]
    public function __invoke(Request $request): Response
    {
        $flow = $this->createForm(UserSignUpType::class, new UserSignUp())
            ->handleRequest($request);

        if ($flow->isSubmitted() && $flow->isValid() && $flow->isFinished()) {
            // do something with $form->getData()

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

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

This follows the classic form creation and handling pattern, with 2 key differences:
 * The check `$flow->isFinished()` to know if form flow was marked as finished (when the finish flow button was clicked),
 * The `$flow->getStepForm()` call, which creates a new step form, when necessary, based on the current state.

Don't be misled by the `$flow` variable name, it's just a `Form` descendant with `FormFlow` capabilities.

> [!IMPORTANT]
>The form data will be stored across steps, meaning the initial data set during the FormFlow creation won't match the one returned by `$form->getData()` at the end. Therefore, _always_ use `$form->getData()` when the flow finishes.

## `ButtonFlowType`

A FlowButton is a regular submit button with a handler (a callable). It mainly handles step transitions but can also run custom logic tied to your form data.

There are 4 built-in Flow button types:
 * `ResetFlowType`: sends the FormFlow back to the initial state (will depend on the initial data),
 * `NextFlowType`: moves to the next step,
 * `PreviousFlowType`: goes to a previous step,
 * `FinishFlowType`: same as `reset` but also marks the FormFlow as finished.

You can combine these options of these buttons for different purposes, for example:
 * A `skip` button using the `NextFlowType` and `clear_submission = true` moves the FormFlow forward while clearing the current step,
 * A `back_to` button using the `PreviousFlowType` and a view value (step name) returns to a specific previous step,

Built-in flow buttons will have a default handler, but you can define a custom handler for specific needs. The `handler` option uses the following signature:

```php
function (UserSignUp $data, ButtonFlowInterface $button, FormFlowInterface $flow) {
    // $data is the current data bound to the form the button belongs to,
    // $button is the flow button clicked,
    // $flow is the FormFlow that the button belongs to, $flow->moveNext(), $flow->movePrevious(), ...
}
```

> [!IMPORTANT]
>By default, the callable handler is executed when the form is submitted, passes validation, and just before the next step form is created during `$flow->getStepForm()`. To control it manually, check if `$flow->getClickedButton()` is set and call `$flow->getClickedButton()->handle()` after `$flow->handleRequest($request)` where needed.

`ButtonFlowType` also comes with other 2 options:
 * `clear_submission`: If true, it clears the submitted data. This is especially handy for `skip` and `previous` buttons, or anytime you want to empty the current step form submission.
 * `include_if`: `null` if you want to include the button in all steps (default), an array of steps, or a callable that’s triggered during form creation to decide whether the flow button should be included in the current step form. This callable will receive the `FormFlowCursor` instance as argument.

## Other Building Blocks

<details>
<summary><h4>FormFlowCursor</h4></summary>

This immutable value object holds all defined steps and the current one. You can access it via `$flow->getCursor()` or as a `FormView` variable in Twig to build a nice step progress UI.

</details>

<details>
<summary><h4>NavigatorFlowType</h4></summary>

The built-in `NavigatorFlowType` provides 3 default flow buttons: `previous`, `next`, and `finish`. You can customize or add more if needed. Here’s an example of adding a “skip” button to the `professional` step we defined earlier:

```php
class UserSignUpNavigatorType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder->add('skip', NextFlowType::class, [
            'clear_submission' => true,
            'include_if' => ['professional'], // the step names where the button will appear
        ]);
    }

    public function getParent(): string
    {
        return NavigatorFlowType::class;
    }
}
```
Then use `UserSignUpNavigatorType` instead.
</details>

<details>
<summary><h4>Data Storage</h4></summary>

FormFlow handles state across steps, so the final data includes everything collected throughout the flow. By default, it uses `SessionDataStorage` (unless you’ve configured a custom one). For testing, `InMemoryDataStorage` is also available.

You can also create custom data storage by implementing `DataStorageInterface` and passing it through the `data_storage` option in `FormFlowType`.

</details>

<details>
<summary><h4>Step Accessor</h4></summary>

The `step_accessor` option lets you control how the current step is read from or written to your data. By default, `PropertyPathStepAccessor` handles this using the form’s bound data and `PropertyAccess` component. If the step name is managed externally (e.g., by a workflow), you can create a custom `StepAccessorInterface` adapter and pass it through this option in `FormFlowType`.

</details>

<details>
<summary><h4>Validation</h4></summary>

FormFlow relies on the standard validation system but introduces a useful convention: it sets the current step as an active validation group. This allows step-specific validation rules without extra setup:

```php
final class FormFlowType extends AbstractFlowType
{
    public function configureOptions(OptionsResolver $resolver): void
    {
        // ...

        $resolver->setDefault('validation_groups', function (FormFlowInterface $flow) {
            return ['Default', $flow->getCursor()->getCurrentStep()];
        });
    }
}
```

Allowing you to configure the validation `groups` in your constraints, like this:

```php
class UserSignUp
{
    public function __construct(
        #[Valid(groups: ['personal'])]
        public Personal $personal  = new Personal(),

        #[Valid(groups: ['professional'])]
        public Professional $professional = new Professional(),

        #[Valid(groups: ['account'])]
        public Account $account = new Account(),

        public string $currentStep = 'personal',
    ) {
    }
}
```
</details>

<details>
<summary><h4>Type Extension</h4></summary>

FormFlowType is a regular form type in the Form system, so you can use `AbstractTypeExtension` to extend one or more of them:

```php
class UserSignUpTypeExtension extends AbstractTypeExtension
{
    /**
     * `@param` FormFlowBuilderInterface $builder
     */
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder->addStep('role', UserSignUpRoleType::class, priority: 1); // added to the beginning cos higher priority
        $builder->removeStep('account');
        if ($builder->hasStep('professional')) {
            $builder->getStep('professional')->setSkip(fn (UserSignUp $data) => !$data->personal->working);
        }
        $builder->addStep('onboarding', UserSignUpOnboardingType::class); // added at the end
    }

    public static function getExtendedTypes(): iterable
    {
        yield UserSignUpType::class;
    }
}
```
</details>

---

There’s a lot more to share about this feature, so feel free to ask if anything isn’t clear.

Cheers!

Commits
-------

2d56b67 Add FormFlow for multistep forms management
symfony-splitter pushed a commit to symfony/framework-bundle that referenced this pull request Oct 24, 2025
…yceruto)

This PR was merged into the 7.4 branch.

Discussion
----------

[Form] Add `FormFlow` for multistep forms management

| Q             | A
| ------------- | ---
| Branch?       | 7.4
| Bug fix?      | no
| New feature?  | yes
| Deprecations? | no
| Issues        | -
| License       | MIT

Alternative to
* symfony/symfony#59548

Inspired on `@silasjoisten`'s work and `@craue`'s [CraueFormFlowBundle](https://github.com/craue/CraueFormFlowBundle), thank you!

# FormFlow

This PR introduces `FormFlow`, a kind of super component built on top of the existing `Form` architecture. It handles the definition, creation, and handling of multistep forms, including data management, submit buttons, and validations across steps.

![formflow](https://github.com/user-attachments/assets/8a60a447-e43e-4ea5-8d77-2351522ac95a)

Demo app: https://github.com/yceruto/formflow-demo
Slides: https://speakerdeck.com/yceruto/formflow-build-stunning-multistep-forms

## `AbstractFlowType`

Just like `AbstractType` defines a single form based on the `FormType`, `AbstractFlowType` can be used to define a multistep form based on `FormFlowType`.

```php
class UserSignUpType extends AbstractFlowType
{
    public function buildFormFlow(FormFlowBuilderInterface $builder, array $options): void
    {
        $builder->addStep('personal', UserSignUpPersonalType::class);
        $builder->addStep('professional', UserSignUpProfessionalType::class);
        $builder->addStep('account', UserSignUpAccountType::class);

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

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

The step name comes from the first param of `addStep()`, which matches the form name, like this:
 * The `personal` form of type `UserSignUpPersonalType` will be the step `personal`,
 * The `professional` form of type `UserSignUpProfessionalType` will be the step `professional`,
 * and so on.

When the form is created, the `currentStep` value determines which step form to build, only the matching one, from the steps defined above, will be built.

## Controller

Use the existent `createForm()` in your controller to create a `FormFlow` instance.

```php
class UserSignUpController extends AbstractController
{
    #[Route('/signup')]
    public function __invoke(Request $request): Response
    {
        $flow = $this->createForm(UserSignUpType::class, new UserSignUp())
            ->handleRequest($request);

        if ($flow->isSubmitted() && $flow->isValid() && $flow->isFinished()) {
            // do something with $form->getData()

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

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

This follows the classic form creation and handling pattern, with 2 key differences:
 * The check `$flow->isFinished()` to know if form flow was marked as finished (when the finish flow button was clicked),
 * The `$flow->getStepForm()` call, which creates a new step form, when necessary, based on the current state.

Don't be misled by the `$flow` variable name, it's just a `Form` descendant with `FormFlow` capabilities.

> [!IMPORTANT]
>The form data will be stored across steps, meaning the initial data set during the FormFlow creation won't match the one returned by `$form->getData()` at the end. Therefore, _always_ use `$form->getData()` when the flow finishes.

## `ButtonFlowType`

A FlowButton is a regular submit button with a handler (a callable). It mainly handles step transitions but can also run custom logic tied to your form data.

There are 4 built-in Flow button types:
 * `ResetFlowType`: sends the FormFlow back to the initial state (will depend on the initial data),
 * `NextFlowType`: moves to the next step,
 * `PreviousFlowType`: goes to a previous step,
 * `FinishFlowType`: same as `reset` but also marks the FormFlow as finished.

You can combine these options of these buttons for different purposes, for example:
 * A `skip` button using the `NextFlowType` and `clear_submission = true` moves the FormFlow forward while clearing the current step,
 * A `back_to` button using the `PreviousFlowType` and a view value (step name) returns to a specific previous step,

Built-in flow buttons will have a default handler, but you can define a custom handler for specific needs. The `handler` option uses the following signature:

```php
function (UserSignUp $data, ButtonFlowInterface $button, FormFlowInterface $flow) {
    // $data is the current data bound to the form the button belongs to,
    // $button is the flow button clicked,
    // $flow is the FormFlow that the button belongs to, $flow->moveNext(), $flow->movePrevious(), ...
}
```

> [!IMPORTANT]
>By default, the callable handler is executed when the form is submitted, passes validation, and just before the next step form is created during `$flow->getStepForm()`. To control it manually, check if `$flow->getClickedButton()` is set and call `$flow->getClickedButton()->handle()` after `$flow->handleRequest($request)` where needed.

`ButtonFlowType` also comes with other 2 options:
 * `clear_submission`: If true, it clears the submitted data. This is especially handy for `skip` and `previous` buttons, or anytime you want to empty the current step form submission.
 * `include_if`: `null` if you want to include the button in all steps (default), an array of steps, or a callable that’s triggered during form creation to decide whether the flow button should be included in the current step form. This callable will receive the `FormFlowCursor` instance as argument.

## Other Building Blocks

<details>
<summary><h4>FormFlowCursor</h4></summary>

This immutable value object holds all defined steps and the current one. You can access it via `$flow->getCursor()` or as a `FormView` variable in Twig to build a nice step progress UI.

</details>

<details>
<summary><h4>NavigatorFlowType</h4></summary>

The built-in `NavigatorFlowType` provides 3 default flow buttons: `previous`, `next`, and `finish`. You can customize or add more if needed. Here’s an example of adding a “skip” button to the `professional` step we defined earlier:

```php
class UserSignUpNavigatorType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder->add('skip', NextFlowType::class, [
            'clear_submission' => true,
            'include_if' => ['professional'], // the step names where the button will appear
        ]);
    }

    public function getParent(): string
    {
        return NavigatorFlowType::class;
    }
}
```
Then use `UserSignUpNavigatorType` instead.
</details>

<details>
<summary><h4>Data Storage</h4></summary>

FormFlow handles state across steps, so the final data includes everything collected throughout the flow. By default, it uses `SessionDataStorage` (unless you’ve configured a custom one). For testing, `InMemoryDataStorage` is also available.

You can also create custom data storage by implementing `DataStorageInterface` and passing it through the `data_storage` option in `FormFlowType`.

</details>

<details>
<summary><h4>Step Accessor</h4></summary>

The `step_accessor` option lets you control how the current step is read from or written to your data. By default, `PropertyPathStepAccessor` handles this using the form’s bound data and `PropertyAccess` component. If the step name is managed externally (e.g., by a workflow), you can create a custom `StepAccessorInterface` adapter and pass it through this option in `FormFlowType`.

</details>

<details>
<summary><h4>Validation</h4></summary>

FormFlow relies on the standard validation system but introduces a useful convention: it sets the current step as an active validation group. This allows step-specific validation rules without extra setup:

```php
final class FormFlowType extends AbstractFlowType
{
    public function configureOptions(OptionsResolver $resolver): void
    {
        // ...

        $resolver->setDefault('validation_groups', function (FormFlowInterface $flow) {
            return ['Default', $flow->getCursor()->getCurrentStep()];
        });
    }
}
```

Allowing you to configure the validation `groups` in your constraints, like this:

```php
class UserSignUp
{
    public function __construct(
        #[Valid(groups: ['personal'])]
        public Personal $personal  = new Personal(),

        #[Valid(groups: ['professional'])]
        public Professional $professional = new Professional(),

        #[Valid(groups: ['account'])]
        public Account $account = new Account(),

        public string $currentStep = 'personal',
    ) {
    }
}
```
</details>

<details>
<summary><h4>Type Extension</h4></summary>

FormFlowType is a regular form type in the Form system, so you can use `AbstractTypeExtension` to extend one or more of them:

```php
class UserSignUpTypeExtension extends AbstractTypeExtension
{
    /**
     * `@param` FormFlowBuilderInterface $builder
     */
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder->addStep('role', UserSignUpRoleType::class, priority: 1); // added to the beginning cos higher priority
        $builder->removeStep('account');
        if ($builder->hasStep('professional')) {
            $builder->getStep('professional')->setSkip(fn (UserSignUp $data) => !$data->personal->working);
        }
        $builder->addStep('onboarding', UserSignUpOnboardingType::class); // added at the end
    }

    public static function getExtendedTypes(): iterable
    {
        yield UserSignUpType::class;
    }
}
```
</details>

---

There’s a lot more to share about this feature, so feel free to ask if anything isn’t clear.

Cheers!

Commits
-------

2d56b67d8f5 Add FormFlow for multistep forms management
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

10 participants