Skip to content

Commit de102ba

Browse files
feature #60212 [Form] Add FormFlow for multistep forms management (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
2 parents 70dbd30 + f0cc718 commit de102ba

File tree

2 files changed

+7
-2
lines changed

2 files changed

+7
-2
lines changed

Resources/config/form.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
use Symfony\Component\Form\Extension\DependencyInjection\DependencyInjectionExtension;
2626
use Symfony\Component\Form\Extension\HtmlSanitizer\Type\TextTypeHtmlSanitizerExtension;
2727
use Symfony\Component\Form\Extension\HttpFoundation\HttpFoundationRequestHandler;
28+
use Symfony\Component\Form\Extension\HttpFoundation\Type\FormFlowTypeSessionDataStorageExtension;
2829
use Symfony\Component\Form\Extension\HttpFoundation\Type\FormTypeHttpFoundationExtension;
2930
use Symfony\Component\Form\Extension\Validator\Type\FormTypeValidatorExtension;
3031
use Symfony\Component\Form\Extension\Validator\Type\RepeatedTypeValidatorExtension;
@@ -127,6 +128,10 @@
127128
->args([service('form.type_extension.form.request_handler')])
128129
->tag('form.type_extension')
129130

131+
->set('form.type_extension.form.flow.session_data_storage', FormFlowTypeSessionDataStorageExtension::class)
132+
->args([service('request_stack')->ignoreOnInvalid()])
133+
->tag('form.type_extension')
134+
130135
->set('form.type_extension.form.request_handler', HttpFoundationRequestHandler::class)
131136
->args([service('form.server_params')])
132137

composer.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
"symfony/dom-crawler": "^6.4|^7.0|^8.0",
4747
"symfony/dotenv": "^6.4|^7.0|^8.0",
4848
"symfony/polyfill-intl-icu": "~1.0",
49-
"symfony/form": "^6.4|^7.0|^8.0",
49+
"symfony/form": "^7.4|^8.0",
5050
"symfony/expression-language": "^6.4|^7.0|^8.0",
5151
"symfony/html-sanitizer": "^6.4|^7.0|^8.0",
5252
"symfony/http-client": "^6.4|^7.0|^8.0",
@@ -90,7 +90,7 @@
9090
"symfony/dotenv": "<6.4",
9191
"symfony/dom-crawler": "<6.4",
9292
"symfony/http-client": "<6.4",
93-
"symfony/form": "<6.4",
93+
"symfony/form": "<7.4",
9494
"symfony/lock": "<6.4",
9595
"symfony/mailer": "<6.4",
9696
"symfony/messenger": "<7.4",

0 commit comments

Comments
 (0)