Upgrade to Pro — share decks privately, control downloads, hide ads and more …

FormFlow - Build Stunning Multistep Forms

FormFlow - Build Stunning Multistep Forms

FormFlow is a powerful Symfony Form feature designed to help developers build elegant, dynamic, and user-friendly multistep forms with minimal effort. Whether you’re collecting detailed onboarding data, processing multi-page checkout workflows, or guiding users through a complex registration journey, FormFlow gives you the structure and flexibility you need—without sacrificing maintainability or user experience.

Avatar for Yonel Ceruto González

Yonel Ceruto González

June 13, 2025
Tweet

Other Decks in Programming

Transcript

  1. @yceruto Multistep Form? Step 1 of 3 • Easier to

    Use • Improves Focus • Better UX • Reduces Errors NEXT Step 2 of 3 BACK NEXT Step 3 of 3 BACK FINISH
  2. @yceruto Case Studies • Booking • Shopping • Onboarding •

    … 1 2 3 Flight Selection Passenger Information Payment & Confirmation BOOK
  3. @yceruto Building Blocks • Data Step 1 Step 2 Step

    3 • propertyA • propertyB • … • propertyF Data • propertyG • propertyH • … • propertyK • propertyV • propertyX • … • propertyZ Form 1 2 3
  4. @yceruto namespace App\Form\Data; use Symfony\Component\Validator\Constraints as Assert; class CheckIn {

    public function __construct( public Passenger $passenger = new Passenger(), public Security $security = new Security(), public Boarding $boarding = new Boarding(), ) { } } Building Blocks • Data 1 2 3
  5. @yceruto Building Blocks • Forms & Types Data Step1Type Step2Type

    Step3Type FormFlow FormFlowType ✨ ✨ NEXT FormFlowActionType FormFlowNavigatorType ✨ ✨
  6. @yceruto SESSION IN-MEMORY Building Blocks Step 1 Step 2 Step

    3 • propertyA • propertyB • … • propertyF Data • propertyG • propertyH • … • propertyK • propertyV • propertyX • … • propertyZ DATA STORAGE FormFlow 1 2 3 • Data Storage - load() - save() - clear() ✨
  7. @yceruto Building Blocks Step 1 Step 2 Step 3 Data

    STEP ACCESSOR currentStep = step_2 • Step Accessor - read() - write() PROPERTY PATH ✨ Step 1 Step 2 Step 3 FormFlow
  8. @yceruto Step 1 Step 2 Step 3 Building Blocks •

    Cursor & Variables Step 1 of 3 Step 2 of 3 Step 3 of 3 NEXT BACK NEXT BACK FINISH
  9. @yceruto namespace App\Form\Data; use Symfony\Component\Validator\Constraints as Assert; class CheckIn {

    public function __construct( #[Assert\Valid(groups: ['passenger'])] public Passenger $passenger = new Passenger(), #[Assert\Valid(groups: ['security'])] public Security $security = new Security(), #[Assert\Valid(groups: ['boarding'])] public Boarding $boarding = new Boarding(), public string $currentStep = 'passenger', ) { } } ←Data • Form Types • Controller • Template • UI How to build one?
  10. @yceruto namespace App\Form\Type\Step; // ... class PassengerType extends AbstractType {

    public function buildForm(FormBuilderInterface $builder, array $options) { $builder ->add('firstName') ->add('lastName') ->add('email', EmailType::class) ->add('passportNumber') ->add('bookingReference') ->add('hasBaggage', CheckboxType::class, [ 'required' => false, ]); } public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults([ 'label' => 'Passenger', 'help' => 'Enter the passenger details and flight information.', 'data_class' => Passenger::class, ]); How to build one? ✓ Data ←Form Types • Controller • Template • UI
  11. @yceruto namespace App\Form\Type; use Symfony\Component\Form\Flow\AbstractFlowType; // ... class CheckInType extends

    AbstractFlowType { /** * @param FormFlowBuilderInterface $builder */ public function buildForm(FormBuilderInterface $builder, array $options) { $builder->addStep('passenger', PassengerType::class); $builder->addStep('security', SecurityType::class); $builder->addStep('boarding', BoardingType::class); } public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults([ 'data_class' => CheckIn::class, 'step_property_path' => 'currentStep', ]); } How to build one? ✓ Data ←Form Types • Controller • Template • UI
  12. @yceruto $builder->addStep( name: 'baggage', type: BaggageType::class, options: ['label' => 'Luggage'],

    skip: fn (CheckIn $data) => !$data->passenger->hasBaggage, priority: 1, ); Step 1 of 3 NEXT Step 2 of 3 BACK NEXT Step 3 of 3 BACK FINISH $builder->getStep('baggage') ->setSkip(fn (CheckIn $data) => ...) ->setPriority(1); How to build one? ✓ Data ←Form Types • Controller • Template • UI
  13. @yceruto namespace App\Form\Type; use Symfony\Component\Form\Extension\Core\Type\FormFlowNavigatorType; use Symfony\Component\Form\Flow\AbstractFlowType; // ... class

    CheckInType extends AbstractFlowType { /** * @param FormFlowBuilderInterface $builder */ public function buildForm(FormBuilderInterface $builder, array $options) { $builder->addStep('passenger', PassengerType::class); $builder->addStep('security', SecurityType::class); $builder->addStep('boarding', BoardingType::class); $builder->add('navigator', FormFlowNavigatorType::class); } public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults([ 'data_class' => CheckIn::class, 'step_property_path' => 'currentStep', ]); } How to build one? ✓ Data ←Form Types • Controller • Template • UI
  14. @yceruto namespace Symfony\Component\Form\Extension\Core\Type; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; class

    FormFlowNavigatorType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options) { $builder->add('back', FormFlowActionType::class, [ 'action' => 'back', ]); $builder->add('next', FormFlowActionType::class, [ 'action' => 'next', ]); $builder->add('finish', FormFlowActionType::class, [ 'action' => 'finish', ]); } } How to build one? ✓ Data ←Form Types • Controller • Template • UI
  15. @yceruto Step 1 Step 2 Step 3 NEXT BACK NEXT

    BACK FINISH Submit Render - Validate - Action > Handler: MoveNext() - Create FormFlow How to build one? ✓ Data ←Form Types • Controller • Template • UI
  16. @yceruto Step 1 Step 2 Step 3 NEXT BACK NEXT

    BACK FINISH Render - Validate - Action > Handler: MoveNext() - Create FormFlow How to build one? ✓ Data ←Form Types • Controller • Template • UI Submit
  17. @yceruto namespace App\Controller; use App\Form\Data\CheckIn; use App\Form\Type\CheckInType; // ... class

    CheckInController extends AbstractController { #[Route('/', name: 'app_check_in')] public function __invoke(Request $request): Response { $flow = $this->createForm(CheckInType::class, new CheckIn()) ->handleRequest($request); if ($flow->isSubmitted() && $flow->isValid() && $flow->isFinished()) { // Persist check-in: $flow->getData() return $this->redirectToRoute('app_check_in'); } return $this->render('check_in/flow.html.twig', [ 'form' => $flow->getStepForm(), ]); } } How to build one? ✓ Data ✓ Form Types ←Controller • Template • UI
  18. @yceruto {% extends 'base.html.twig' %} {% block body %} <div

    class="container"> <div class="row"> <div class="col-8"> <div> {{ form(form) }} </div> </div> </div> </div> {% endblock %} How to build one? ✓ Data ✓ Form Types ✓ Controller ←Template • UI
  19. @yceruto How to build one? ✓ Data ✓ Form Types

    ✓ Controller ✓ Template ←UI (raw)
  20. @yceruto How to build one? ✓ Data ✓ Form Types

    ✓ Controller ✓ Template ←UI (custom) {% for step in form.vars.steps %} {% endfor %}
  21. @yceruto Customization ←Type Extension • Data Storage • Step Accessor

    • Action namespace App\Form\Type\Extension; // ... class CheckInTypeExtension extends AbstractTypeExtension { /** * @param FormFlowBuilderInterface $builder */ public function buildForm(FormBuilderInterface $builder, array $options) { $builder->addStep('first', NewFirstStepType::class, priority: 1); if ($builder->hasStep('baggage')) { $builder->removeStep('baggage'); } $builder->addStep('last', NewLastStepType::class); } public static function getExtendedTypes(): iterable { yield CheckInType::class; } }
  22. @yceruto Customization ✓ Type Extension ←Data Storage • Step Accessor

    • Action namespace Symfony\Component\Form\Flow\DataStorage; interface DataStorageInterface { public function save(object|array $data): void; public function load(object|array|null $default = null): object|array|null; public function clear(): void; } namespace App\Form\Type; class CheckInType extends AbstractFlowType { // ... public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults([ 'data_class' => CheckIn::class, 'data_storage' => new DoctrineEntityStorage(), ]); } }
  23. @yceruto Customization ✓ Type Extension ✓ Data Storage ←Step Accessor

    • Action namespace Symfony\Component\Form\Flow\DataStorage; interface StepAccessorInterface { public function getStep(object|array $data, ?string $default = null): ?string; public function setStep(object|array &$data, string $step): void; } namespace App\Form\Type; class CheckInType extends AbstractFlowType { // ... public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults([ 'data_class' => CheckIn::class, 'step_accessor' => new ReflectionStepAccessor('currentStep'), ]); } }
  24. @yceruto Customization RESET $builder->add('reset', FormFlowActionType::class, [ 'action' => 'reset', ]);

    BACK NEXT FINISH ✓ Type Extension ✓ Data Storage ✓ Step Accessor ←Action FormFlowActionType
  25. @yceruto ACTION Action Handler Include If Clear Submission reset: back:

    next: finish: reset() moveBack() moveNext() finished, reset() null canMoveBack() canMoveNext() isLastStep() true true false false Validate & Validation_groups false false true true Customization • action • handler • include_if • clear_submission • validate • validation_groups ←Action FormFlowActionType
  26. @yceruto $builder->add('skip', FormFlowActionType::class, [ 'action' => 'next', 'clear_submission' => true,

    'validate' => false, 'validation_groups' => false, ]); SKIP • action • handler • include_if • clear_submission • validate • validation_groups ←Action Customization FormFlowActionType
  27. @yceruto $builder->add('back_to', FormFlowActionType::class, [ 'action' => 'back', 'validate' => false,

    'validation_groups' => false, 'clear_submission' => false, ]); 1 2 3 4 $flow->submit([ 'step_4' => [ 'field' => 'value', ], 'navigator' => [ 'back_to' => 'step_2', ], ]); • action • handler • include_if • clear_submission • validate • validation_groups ←Action Customization FormFlowActionType BACK
  28. @yceruto namespace App\Form\Type\Step; // ... class BaggageType extends AbstractType {

    public function buildForm(FormBuilderInterface $builder, array $options) { // ... $builder ->add('items', CollectionType::class, [ 'entry_type' => BaggageItemType::class, 'prototype' => false, 'allow_add' => true, 'allow_delete' => true, 'error_bubbling' => false, ]) ->add('add', FormFlowActionType::class, [ 'validate' => false, 'validation_groups' => false, 'handler' => function (Baggage $data) { $data->items[] = new BaggageItem(); }, ]); } } • Inter-step ←Action Customization
  29. @yceruto namespace App\Form\Type\Step; // ... class BaggageType extends AbstractType {

    public function buildForm(FormBuilderInterface $builder, array $options) { // ... $builder ->add('items', CollectionType::class, [ 'entry_type' => BaggageItemType::class, 'prototype' => false, 'allow_add' => true, 'allow_delete' => true, 'error_bubbling' => false, ]) ->add('add', FormFlowActionType::class, [ 'validate' => false, 'validation_groups' => false, 'handler' => function (Baggage $data) { $data->items[] = new BaggageItem(); }, ]); } } • Inter-step ←Action Customization
  30. @yceruto namespace App\Form\Type\Collection; class BaggageItemType extends AbstractType { public function

    buildForm(FormBuilderInterface $builder, array $options) { $builder ->add('tagNumber', null, [ 'attr' => ['placeholder' => 'Tag number'], ]) ->add('weight', NumberType::class, [ 'attr' => ['placeholder' => 'Weight (kg)'], ]) ->add('remove', FormFlowActionType::class, [ 'label' => false, 'validate' => false, 'validation_groups' => false, 'attr' => ['value' => $builder->getName()], 'handler' => function (BaggageItem $data, ActionButtonInterface $button, FormFlowInterface $flow, ) { unset($flow->getData()->baggage->items[$button->getViewData()]); }, ]); } } • Inter-step ←Action Customization
  31. @yceruto • Inter-step ←Action Customization namespace App\Form\Type\Collection; class BaggageItemType extends

    AbstractType { public function buildForm(FormBuilderInterface $builder, array $options) { $builder ->add('tagNumber', null, [ 'attr' => ['placeholder' => 'Tag number'], ]) ->add('weight', NumberType::class, [ 'attr' => ['placeholder' => 'Weight (kg)'], ]) ->add('remove', FormFlowActionType::class, [ 'label' => false, 'validate' => false, 'validation_groups' => false, 'attr' => ['value' => $builder->getName()], 'handler' => function (BaggageItem $data, ActionButtonInterface $button, FormFlowInterface $flow, ) { unset($flow->getData()->baggage->items[$button->getViewData()]); }, ]); } }