Skip to content

[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

Open
wants to merge 1 commit into
base: 7.4
Choose a base branch
from

Conversation

yceruto
Copy link
Member

@yceruto yceruto commented Apr 13, 2025

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

Alternative to

Inspired 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 existing Form architecture. It handles the definition, creation, and handling of multistep forms, including data management, submit buttons, and validations across steps.

formflow

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.

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', FlowNavigatorType::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.

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.

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 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 FlowNextType and clear_submission = true moves the FormFlow forward while clearing the current step,
  • A back_to button using the FlowPreviousType 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:

function (UserSignUp $data, FlowButtonInterface $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.

FlowButtonType 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 FlowCursor 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 a FormView variable in Twig to build a nice step progress UI.

FlowNavigatorType

The built-in FlowNavigatorType 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:

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

    public function getParent(): string
    {
        return FlowNavigatorType::class;
    }
}

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 the data_storage option in FormFlowType.

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 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.

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:

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:

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',
    ) {
    }
}

Type Extension

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

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;
    }
}

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

Cheers!

@yceruto yceruto added the Form label Apr 13, 2025
@yceruto yceruto requested a review from xabbuh as a code owner April 13, 2025 23:19
@carsonbot carsonbot added this to the 7.3 milestone Apr 13, 2025
@symfony symfony deleted a comment from carsonbot Apr 13, 2025
@yceruto yceruto marked this pull request as draft April 13, 2025 23:25
Copy link
Contributor

@94noni 94noni left a comment

Choose a reason for hiding this comment

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

early raw reviews :)

@connorhu
Copy link
Contributor

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?
May the answer is can't by default. I solved this issue by keeping track of which valid step we are at and the url contains the step number (1..n), so I can generate a url for the previous steps, but I dont allow to go to not-yet-valid next steps.
I think I can see the points where I need to customize to achieve this.

@yceruto
Copy link
Member Author

yceruto commented Apr 14, 2025

how can I jump back to a specific step without having to press the back button many times?

Hey! take a look at the testMoveBackToStep() test, it covers this case. It's possible via submit operation or manually using $flow->movePrevious('step') directly.

@yceruto yceruto force-pushed the formflow branch 2 times, most recently from 2f81d34 to cdac9e2 Compare April 15, 2025 00:16
@RafaelKr
Copy link

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 UploadedFile (yet).
This was one of the big challenges we had to solve with CraueFormFlowBundle in combination with https://rekalogika.dev/file-bundle/file-upload-filepond, especially to make restore work (when going back to show the previously submitted files) and then to handle those if the step is submitted again.

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:

  1. Initial upload. I think it would be best to already store the file(s) to a temporary location on the filesystem and just work with references to them.
    In our case we use the Rekalogika file bundle to directly reference Files with our Doctrine Media Entity (see https://rekalogika.dev/file-bundle/doctrine-entity and https://rekalogika.dev/file-bundle/working-with-entities) and then store the Media entity inside our Form Data.
  2. "Restore" already uploaded file(s) when the user goes back to a step with a file upload field.
  3. Handle resubmit, especially for file inputs with multiple attribute. If the same step is submitted more then once, the following cases need to be handled:
  • keep unchanged files
  • delete files which were removed
  • create newly uploaded files
  1. Final submission. Maybe we want to move files from a temporary location to persistent storage.

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.

@stof
Copy link
Member

stof commented Apr 15, 2025

  1. I think it would be best to already store the file(s) to a temporary location on the filesystem and just work with references to them.

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)

@RafaelKr
Copy link

  1. I think it would be best to already store the file(s) to a temporary location on the filesystem and just work with references to them.

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 FileUploadHandlerInterface could be introduced. I don't really like the default handling of CraueFormFlowBundle (storing files as base64 to the session), but also can't come up with a better approach which would be compatible with horizontal scaling.

@yceruto
Copy link
Member Author

yceruto commented Apr 15, 2025

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 next flow button with a dedicated handler where file uploads can be managed. Then, you can keep track of the uploaded files by saving the references in the DTO, which is preserved between steps.

Imagine you have a documents step defined for files uploading. In your navigator form, you can define this new button:

$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 $data->uploadedFiles.

@yceruto
Copy link
Member Author

yceruto commented Apr 15, 2025

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 CollectionType). In those cases, the step stays the same, but the bound data gets updated, and the form is rebuilt when $flow->getStepForm() is called again, now with new data and form updated.

This part feels a bit like magic, but it’s a helpful one given how complex multistep forms can be.

@yceruto yceruto force-pushed the formflow branch 9 times, most recently from 1ef730f to 4df48d8 Compare April 24, 2025 14:20
@yceruto
Copy link
Member Author

yceruto commented Jul 24, 2025

Hey there! Just one more review & vote and we can merge this into 7.4 😊 Friendly ping to @symfony/mergers.

A quick reminder: there’s a demo app available if you’d like to test it out first. You can also check out the mirrored bundle featuring the same code.

Thanks! 🙌

@yceruto yceruto force-pushed the formflow branch 3 times, most recently from 9cb35db to ab49dc4 Compare July 26, 2025 23:52
@yceruto
Copy link
Member Author

yceruto commented Jul 27, 2025

Thanks @smnandre for your review!

Summary of the latest changes:

  • Renamed back to previous (wherever it makes sense)
  • Improved class naming for Flow button, type, step, builder, cursor, and navigator (now shorter and more concise)
  • Removed the action option from FlowButtonType in favor of dedicated types: FlowResetType, FlowPreviousType, FlowNextType, and FlowFinishType, each with specific options tailored to their respective actions

The description of the PR was also updated to reflect the current state of the code.

It's ready for code review again!

@yceruto yceruto force-pushed the formflow branch 3 times, most recently from 4765f47 to 439d7f1 Compare July 29, 2025 09:51
@yceruto
Copy link
Member Author

yceruto commented Jul 29, 2025

To improve DX, I added a new method to AbstractFlowType that exposes the FormFlowBuilderInterface signature directly. This would eliminate the need to rely on phpdoc/assert for guidance/discovery/IDE autocomplete, which is currently unintuitive.

This makes sense to me, by extending AbstractFlowType the developer clearly signals the intent to build a form flow. In practice, it could be like this:

class SignUpFlowType extends AbstractFlowType
{
    public function buildFormFlow(FormFlowBuilderInterface $builder, array $options): void
    {
        // $builder->addStep(...);

        // $builder->add(...);
    }
}

here buildFormFlow() is the new method I'm talking about, while buildForm() can be final in AbstractFlowType, so only the new method can be used.

@yceruto yceruto force-pushed the formflow branch 3 times, most recently from b85c758 to 7c5b9eb Compare July 29, 2025 09:58
@virtualize
Copy link

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).
Are you planning to update the back ported bundle https://github.com/yceruto/formflow-bundle with your lastest changes any time soon?

@yceruto
Copy link
Member Author

yceruto commented Jul 29, 2025

@virtualize I've actually planned to work on that topic today 🙂

@yceruto
Copy link
Member Author

yceruto commented Jul 29, 2025

Updated bundle and demo with latest changes.

@virtualize
Copy link

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:

  • Choose account type (e.g. individual)
  • Continue Button
  • Enter Name & About
  • Continue Button
  • Back Button (to Name & About step)
  • Back Button (to Account type step)
  • Continue Button
  • now the everything in the Name & About step has been cleared

Is this intentional or could this be a bug?

@yceruto
Copy link
Member Author

yceruto commented Aug 9, 2025

@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 FlowPreviousType definition:

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 false you'll disable that behaviour.

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.

@yceruto yceruto force-pushed the formflow branch 2 times, most recently from 00f28bd to d95881e Compare August 9, 2025 11:39
@yceruto
Copy link
Member Author

yceruto commented Aug 9, 2025

I received feedback that creating custom flow buttons will be a common need, so I added a new AbstractFlowButtonType to make their creation easier. Thus, never forgetting to implement FlowButtonTypeInterface and getParent() method targeting FlowButtonType.

abstract class AbstractFlowButtonType extends AbstractType implements FlowButtonTypeInterface
{
    public function getParent(): string
    {
        return FlowButtonType::class;
    }
}

@javiereguiluz
Copy link
Member

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":

Current Name Proposed Alternative
AbstractFlowType AbstractMultiStepType
FlowResetType ResetFormType
FlowNextType NextStepType
FlowPreviousType PreviousStepType
FlowFinishType FinishFormType
FlowNavigatorType MultiStepNavigatorType
FlowButtonInterface MultiStepButtonInterface
FormFlowInterface MultiStepFormInterface
FlowCursor MultiStepCursor

@virtualize
Copy link

virtualize commented Aug 9, 2025

@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.

@yceruto
Copy link
Member Author

yceruto commented Aug 10, 2025

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.

@RafaelKr
Copy link

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.

@94noni
Copy link
Contributor

94noni commented Aug 11, 2025

after my first PR review, I also didnt get the naming
but now I am used to it so indeed it can be good like so

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.