-
-
Notifications
You must be signed in to change notification settings - Fork 9.7k
[Form] Add FormFlow
for multistep forms management
#60212
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: 7.4
Are you sure you want to change the base?
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
early raw reviews :)
src/Symfony/Component/Form/Extension/Core/Type/FormFlowNavigatorType.php
Outdated
Show resolved
Hide resolved
src/Symfony/Component/Form/Extension/Core/Type/FormFlowType.php
Outdated
Show resolved
Hide resolved
...ony/Component/Form/Extension/HttpFoundation/Type/FormFlowTypeSessionDataStorageExtension.php
Outdated
Show resolved
Hide resolved
src/Symfony/Component/Form/Extension/Core/Type/FormFlowActionType.php
Outdated
Show resolved
Hide resolved
src/Symfony/Component/Form/Extension/Core/Type/FormFlowNavigatorType.php
Outdated
Show resolved
Hide resolved
Great idea! I've looked at the previous PR and could use it! One question came up: how can I jump back to a specific step without having to press the back button many times? |
Hey! take a look at the |
2f81d34
to
cdac9e2
Compare
Hey @yceruto, awesome to see this may become a native part of Symfony Forms! I just had a quick look at the implementation and didn't find anything about It would be very helpful to have a default way the FormFlow can handle file uploads, but also to have some interface to interact with the full lifecycle of uploaded files, e.g. to decide where and how to store them, how to reference them inside form data, etc. I think the lifecycle consists of the following steps:
Maybe file uploads are not as important for an initial implementation, but it's definitely a use case which should be thought about. Feel free to ping me if you have any questions. Maybe I could even build a minimal demo of the current implementation we use in our project and share it with you. |
this would be incompatible with apps using a load balancer with several servers, as there is no guarantee that the next request goes to the same server behind the load balancer. Such case require storing uploads in a shared storage (for instance using a S3 bucket or similar storage) |
You're very right, thanks for pointing this out. So it's especially important to have the possibility of handling those different cases. Maybe a |
I haven’t checked the file upload yet because I knew it would be a complicated topic, but I think using a custom flow button (as explained above) will give you the flexibility to do custom things on your own. One option is to create your own Imagine you have a $builder->add('upload', FlowButtonType::class, [
'handler' => function (MyDto $data, FlowButtonInterface $button, FormFlowInterface $flow) {
// handle files uploading here ... store them somewhere ... create references ...
// $data->uploadedFiles = ... save references if we go back ...
$flow->moveNext();
},
'include_if' => ['documents'], // the steps where this button will appear
]); So, it’s up to you where to store the files, how to reference them in the DTO, and how to render them again if the user goes back to this step, just by looking into |
Think of Flow buttons like mini-controllers with a focused job. The main controller handles the shared logic across all steps, while each action handler takes care of custom operations. You can even use them for inter-step operations (like in the demo preview, where I showed how to add or remove skill items from a This part feels a bit like magic, but it’s a helpful one given how complex multistep forms can be. |
1ef730f
to
4df48d8
Compare
9cb35db
to
ab49dc4
Compare
Thanks @smnandre for your review! Summary of the latest changes:
The description of the PR was also updated to reflect the current state of the code. It's ready for code review again! |
4765f47
to
439d7f1
Compare
To improve DX, I added a new method to This makes sense to me, by extending class SignUpFlowType extends AbstractFlowType
{
public function buildFormFlow(FormFlowBuilderInterface $builder, array $options): void
{
// $builder->addStep(...);
// $builder->add(...);
}
} here |
b85c758
to
7c5b9eb
Compare
I really like the implementation and your impressive demos so far. We would like to try the FormFlow in a new feature on our production system (Symfony 7.2). |
@virtualize I've actually planned to work on that topic today 🙂 |
Updated bundle and demo with latest changes. |
I noticed that already submitted form data gets reset if you navigate two steps back (e.g. to simply review your form entries) and then navigate forward again. This behaviour is reproducible with the Basic and SignIn demos. Steps to reproduce with SignIn Demo:
Is this intentional or could this be a bug? |
@virtualize Yes, by default, the back/previous step action clears the submitted data from the current step form. It's like this because, from my experience, most multistep forms I've built have the current step hardly depending on the previous step's data. See class FlowPreviousType extends AbstractType implements FlowButtonTypeInterface
{
// ...
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
// ...
'clear_submission' => true, // <-- data sent will never be mapped into the current step form
]);
}
// ...
} As you may guess, by setting it to However, in the demo (I guess you're referring to the SignUp demo), we are assuming that going back to the previous step will mean discarding any changes made in the current step and restoring the previous step's original state. To review, you can simply click on the step number and move forward again with all the data preserved. |
00f28bd
to
d95881e
Compare
I received feedback that creating custom flow buttons will be a common need, so I added a new abstract class AbstractFlowButtonType extends AbstractType implements FlowButtonTypeInterface
{
public function getParent(): string
{
return FlowButtonType::class;
}
} |
Yonel, thank you so much for this contribution. Words can't do it justice: it's simply brilliant and wonderful ❤️ I love everything you're proposing, but I have some concerns about using the word "flow." The most common names for this type of forms are "multi-step form" or "stepped form" (historically, they were also called "form wizard"). The word "flow" in this context seems a bit vague to me. Since you're already using the term "step" throughout much of this new functionality, why not use it everywhere? Here's how the naming would look if we used "multi-step form":
|
@yceruto thank you for clarifying the FlowButton behaviour. Looking at the FlowPreviousType source this now seems obvious :) Regarding the naming I would much rather prefer the current Flow-based naming. MultiStep sounds a bit cumbersome to me. If we take the Workflow component as an analogy, which also builds flows that are composed of steps, then FormFlow fits perfectly for this component IMHO. |
Thank you Javier for your thoughtful input and kind words! Naming is always a tricky subject: I chose "Flow" because it comes from the popular CraueFormFlowBundle, so it feels familiar to anyone who has used that bundle. Also because the class names stay short, elegant, and readable. However, I've to admit that newcomers might find this terminology a bit confusing and not so obvious at the beginning. I agree that using the term "Multistep" is clearer for people who have never seen this code, but it's also longer and doesn't always add real clarity. I still prefer the more concise/vague "Flow" name (which also works as a prefix to quickly locate these classes) and I believe that once people understand what "FormFlow" means, the code becomes simpler and more readable. So let's gather more opinions before making a decision. |
I also like the short but precise terminology "Flow" and as long as we can find it inside the documentation with those "synonyms" (Multi Step Form, Form Wizard, etc.) I think we're good :) And if those synonyms are also mentioned inside a PHPDoc comment and/or the test cases it's also searchable inside the code-base. |
after my first PR review, I also didnt get the naming |
Alternative to
MultiStepType
#59548Inspired on @silasjoisten's work and @craue's CraueFormFlowBundle, thank you!
FormFlow
This PR introduces
FormFlow
, a kind of super component built on top of the existingForm
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 theFormType
,AbstractFlowType
can be used to define a multistep form based onFormFlowType
.The step name comes from the first param of
addStep()
, which matches the form name, like this:personal
form of typeUserSignUpPersonalType
will be the steppersonal
,professional
form of typeUserSignUpProfessionalType
will be the stepprofessional
,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 aFormFlow
instance.This follows the classic form creation and handling pattern, with 2 key differences:
$flow->isFinished()
to know if form flow was marked as finished (when the finish flow button was clicked),$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 aForm
descendant withFormFlow
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.FlowButtonType
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 FlowButton types:
FlowResetType
: sends the FormFlow back to the initial state (will depend on the initial data),FlowNextType
: moves to the next step,FlowPreviousType
: goes to a previous step,FlowFinishType
: same asreset
but also marks the FormFlow as finished.You can combine these options of these buttons for different purposes, for example:
skip
button using theFlowNextType
andclear_submission = true
moves the FormFlow forward while clearing the current step,back_to
button using theFlowPreviousType
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: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.FlowButtonType
also comes with other 2 options:clear_submission
: If true, it clears the submitted data. This is especially handy forskip
andprevious
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 theFlowCursor
instance as argument.Other Building Blocks
FlowCursor
This immutable value object holds all defined steps and the current one. You can access it via
$flow->getCursor()
or as aFormView
variable in Twig to build a nice step progress UI.FlowNavigatorType
The built-in
FlowNavigatorType
provides 3 default flow buttons:previous
,next
, andfinish
. You can customize or add more if needed. Here’s an example of adding a “skip” button to theprofessional
step we defined earlier:Then use
UserSignUpNavigatorType
instead.Data Storage
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 thedata_storage
option inFormFlowType
.Step Accessor
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 andPropertyAccess
component. If the step name is managed externally (e.g., by a workflow), you can create a customStepAccessorInterface
adapter and pass it through this option inFormFlowType
.Validation
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:
Allowing you to configure the validation
groups
in your constraints, like this:Type Extension
FormFlowType is a regular form type in the Form system, so you can use
AbstractTypeExtension
to extend one or more of them:There’s a lot more to share about this feature, so feel free to ask if anything isn’t clear.
Cheers!