Commit de102ba
committed
feature #60212 [Form] Add
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.

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 managementFormFlow for multistep forms management (yceruto)2 files changed
+7
-2
lines changed| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
25 | 25 | | |
26 | 26 | | |
27 | 27 | | |
| 28 | + | |
28 | 29 | | |
29 | 30 | | |
30 | 31 | | |
| |||
127 | 128 | | |
128 | 129 | | |
129 | 130 | | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
130 | 135 | | |
131 | 136 | | |
132 | 137 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
46 | 46 | | |
47 | 47 | | |
48 | 48 | | |
49 | | - | |
| 49 | + | |
50 | 50 | | |
51 | 51 | | |
52 | 52 | | |
| |||
90 | 90 | | |
91 | 91 | | |
92 | 92 | | |
93 | | - | |
| 93 | + | |
94 | 94 | | |
95 | 95 | | |
96 | 96 | | |
| |||
0 commit comments