Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

[Form] AddFormFlow 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 ourterms of service andprivacy statement. We’ll occasionally send you account related emails.

Already on GitHub?Sign in to your account

Draft
yceruto wants to merge1 commit intosymfony:7.4
base:7.4
Choose a base branch
Loading
fromyceruto:formflow

Conversation

yceruto
Copy link
Member

@ycerutoyceruto commentedApr 13, 2025
edited
Loading

QA
Branch?7.4
Bug fix?no
New feature?yes
Deprecations?no
Issues-
LicenseMIT

Alternative to#59548, inspired on@silasjoisten's work and@craue'sbundle, thank you!

FormFlow

This PR introducesFormFlow, a component built on top of the existingForm architecture. It handles the definition, creation, and handling of multistep forms, including data management,actions (explained below), and validations across steps.

AbstractFlowType

Just likeAbstractType defines a single form based on theFormType,AbstractFlowType can be used to define a multistep form based onFormFlowType.

class UserSignUpTypeextends AbstractFlowType{/**     * {@inheritdoc}     */publicfunctionbuildForm(FormBuilderInterface$builder,array$options):void    {$builder->addStep('personal', UserSignUpPersonalType::class);$builder->addStep('professional', UserSignUpProfessionalType::class);$builder->addStep('account', UserSignUpAccountType::class);$builder->add('navigator', FormFlowNavigatorType::class);    }publicfunctionconfigureOptions(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 ofaddStep(), which matches the form name, like this:

  • Thepersonal form of typeUserSignUpPersonalType will be the steppersonal,
  • Theprofessional form of typeUserSignUpProfessionalType will be the stepprofessional,
  • and so on.

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

Type Extension

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

class UserSignUpTypeExtensionextends AbstractTypeExtension{/**     * @param FormFlowBuilderInterface $builder     */publicfunctionbuildForm(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    }publicstaticfunctiongetExtendedTypes():iterable    {yield UserSignUpType::class;    }}

Controller

Use the existentcreateForm() in your controller to create aFormFlow instance.

class UserSignUpControllerextends AbstractController{    #[Route('/signup')]publicfunction__invoke(Request$request):Response    {$flow =$this->createForm(UserSignUpType::class,newUserSignUp())            ->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 the finish action button was clicked,
  • The$flow->getStepForm() call, which creates the 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.

FormFlowActionType

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

The action name identifies the operation to perform and doesn’t need to match the button name. There are 4 built-in actions:

  • reset: sends the FormFlow back to the initial state (will depend on the initial data),
  • next: moves to the next step,
  • back: goes to a previous step,
  • finish: same asreset but also marks the FormFlow as finished.

You can combine these actions with other options for different purposes, for example:

  • Askip button using thenext action andclear_submission moves the FormFlow forward while clearing the current step,
  • Aback_to button using theback action and a view value (step name) returns to a specific previous step,

Built-in actions have a default handler in the FormFlow class, but you can define custom action/handler for specific needs. Custom handlers use the following signature:

function (UserSignUp$data,ActionButtonInterface$button,FormFlowInterface$flow) {// $data is the current data bound to the form the button belongs to,// $button is the action button clicked, $button->getAction(), $button->getViewData(), ...// $flow is the FormFlow that the button belongs to, $flow->moveNext(), $flow->moveBack(), ...}

Important

By default, the action runs when the form is submitted, passes validation, and just before the next step form is created during$flow->getStepForm(). To control it manually, call$flow->handleAction() after$flow->handleRequest($request) where needed.

FormFlowActionType also comes with other 2 options:

  • clear_submission: If true, it clears the submitted data. This is especially handy forskip andback actions, 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 action button should be included in the current step form. This callable will receive theFormFlowCursor instance as argument.

FormFlowCursor

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.

FormFlowNavigatorType

The built-inFormFlowNavigatorType provides 3 default action buttons:back,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:

class UserSignUpNavigatorTypeextends AbstractType{publicfunctionbuildForm(FormBuilderInterface$builder,array$options):void    {$builder->add('skip', FormFlowActionType::class, ['action' =>'next','clear_submission' =>true,'include_if' => ['professional'],// the step names where the button will appear        ]);    }publicfunctiongetParent():string    {return FormFlowNavigatorType::class;    }}

Then useUserSignUpNavigatorType instead.

Data Storage

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

You can also create custom data storage by implementingDataStorageInterface and passing it through thedata_storage option inFormFlowType.

Step Accessor

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

finalclass FormFlowTypeextends AbstractFlowType  {publicfunctionconfigureOptions(OptionsResolver$resolver):void      {// ...$resolver->setDefault('validation_groups',function (FormFlowInterface$flow) {return ['Default',$flow->getCursor()->getCurrentStep()];          });    }}

Allowing you to configure the validationgroups in your constraints, like this:

class UserSignUp{publicfunction__construct(        #[Valid(groups: ['personal'])]publicPersonal$personal  =newPersonal(),        #[Valid(groups: ['professional'])]publicProfessional$professional =newProfessional(),        #[Valid(groups: ['account'])]publicAccount$account =newAccount(),publicstring$currentStep ='personal',    ) {    }}

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

Cheers!

Demo preview:
formflow

skmedix, Jibbarth, seb-jean, OskarStark, silasjoisten, andreybolonin, connorhu, 94noni, W0rma, willemverspyck, and 13 more reacted with hooray emojiJibbarth, OskarStark, silasjoisten, andreybolonin, connorhu, mvhirsch, Oviglo, RafaelKr, welcoMattic, mahono, and 7 more reacted with heart emoji94noni, chalasr, ker0x, javiereguiluz, PabloKowalczyk, yceruto, jseparovic1, OskarStark, sstok, and Fan2Shrek reacted with rocket emoji
Copy link
Contributor

@94noni94noni 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 :)

yceruto reacted with heart emoji
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('back', FormFlowActionType::class, [
Copy link
Contributor

Choose a reason for hiding this comment

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

what bout "previous" ? generally it goes with "next"
or rename other "forward" ?

Copy link
MemberAuthor

Choose a reason for hiding this comment

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

I don’t have a strong opinion on that. I personally prefer the shorter version, and users can change it using thelabel option anyway. Let’s see what others think.

silasjoisten reacted with thumbs up emoji
use Symfony\Component\PropertyAccess\PropertyPathInterface;

/**
* A multistep form.
Copy link
Contributor

Choose a reason for hiding this comment

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

a question about naming:

you describe this as a form multistep
but the class is named form flow

its feels weird at first read

Copy link
MemberAuthor

Choose a reason for hiding this comment

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

They mean the same to me... I’m just phrasing it differently to make the idea clearer. What is the weird part?

silasjoisten reacted with thumbs up emoji
->default(null)
->allowedTypes('null', 'callable');

$resolver->define('include_if')
Copy link
Contributor

Choose a reason for hiding this comment

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

nice

Copy link
MemberAuthor

@ycerutoycerutoApr 24, 2025
edited
Loading

Choose a reason for hiding this comment

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

I've improved it a bit more for better DX, so now we have three allowed values:

  1. null if you want to include the button in all defined steps,
  2. anarray with the names of the steps where the button will be included, (most used)
  3. acallable for more advanced inclusion logic.

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

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

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

connorhu, OskarStark, and sstok reacted with hooray emoji

@ycerutoycerutoforce-pushed theformflow branch 2 times, most recently from2f81d34 tocdac9e2CompareApril 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 aboutUploadedFile (yet).
This was one of the big challenges we had to solve with CraueFormFlowBundle in combination withhttps://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 DoctrineMedia Entity (seehttps://rekalogika.dev/file-bundle/doctrine-entity andhttps://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 withmultiple 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

  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)

sstok reacted with thumbs up emoji

@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 aFileUploadHandlerInterface could be introduced. I don't really like thedefault 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
MemberAuthor

yceruto commentedApr 15, 2025
edited
Loading

I haven’t checked the file upload yet because I knew it would be a complicated topic, but I think using a custom action button (as explained above) will give you the flexibility to do custom things on your own.

One option is to create your ownnext action button with a dedicatedhandler 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 adocuments step defined for files uploading. In your navigator form, you can define this new action:

$builder->add('upload', FormFlowActionType::class, ['include_if' => ['documents'],// the steps where this button will appear'handler' =>function (MyDto$data,ActionButtonInterface$button,FormFlowInterface$flow) {// handle files uploading here ... store them somewhere ... create references ...// $data->uploadedFiles = ... save references if we go back ...$flow->moveNext();    },]);

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.

RafaelKr reacted with heart emoji

@yceruto
Copy link
MemberAuthor

Think of FormFlow action 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 aCollectionType). 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.

RafaelKr and seb-jean reacted with heart emoji

@ycerutoycerutoforce-pushed theformflow branch 9 times, most recently from1ef730f to4df48d8CompareApril 24, 2025 14:20
@ycerutoycerutoforce-pushed theformflow branch 3 times, most recently from7996e61 tod8cbb5bCompareApril 29, 2025 22:33
@ycerutoycerutoforce-pushed theformflow branch 7 times, most recently from734a122 to0e181d8CompareMay 13, 2025 15:33
@ycerutoyceruto changed the title[Form] AddFormFlow component for multistep forms management[Form] AddFormFlow for multistep forms managementMay 21, 2025
@fabpotfabpot modified the milestones:7.3,7.4May 26, 2025
@ycerutoycerutoforce-pushed theformflow branch 3 times, most recently fromcdc1cc3 tof422726CompareMay 26, 2025 20:42
Sign up for freeto join this conversation on GitHub. Already have an account?Sign in to comment
Reviewers

@94noni94noni94noni left review comments

@xabbuhxabbuhAwaiting requested review from xabbuhxabbuh is a code owner

Assignees
No one assigned
Projects
None yet
Milestone
7.4
Development

Successfully merging this pull request may close these issues.

7 participants
@yceruto@connorhu@RafaelKr@stof@94noni@fabpot@carsonbot

[8]ページ先頭

©2009-2025 Movatter.jp