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 FlowButtonType FlowNavigatorType ✨ ✨
  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 { public function buildFormFlow(FormFlowBuilderInterface $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\Flow\AbstractFlowType; use Symfony\Component\Form\Flow\Type\FlowNavigatorType; // ... class

    CheckInType extends AbstractFlowType { public function buildFormFlow(FormFlowBuilderInterface $builder, array $options) { $builder->addStep('passenger', PassengerType::class); $builder->addStep('security', SecurityType::class); $builder->addStep('boarding', BoardingType::class); $builder->add('navigator', FlowNavigatorType::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\Flow\Type; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Flow\Type\FlowFinishType; use Symfony\Component\Form\Flow\Type\FlowPreviousType; use

    Symfony\Component\Form\Flow\Type\FlowNextType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; class FlowNavigatorType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options) { $builder->add(‘back', FlowPreviousType::class); $builder->add('next', FlowNextType::class); $builder->add('finish', FlowFinishType::class); } } How to build one? ✓ Data ←Form Types • Controller • Template • UI FINISH
  15. @yceruto Step 1 Step 2 Step 3 NEXT BACK NEXT

    BACK FINISH Submit Render - Validate - Button > 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 - Button > 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

    • Button 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

    • Button 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

    • Button 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', FlowResetType::class); BACK NEXT FINISH ✓ Type

    Extension ✓ Data Storage ✓ Step Accessor ←Button FlowButtonType
  25. @yceruto BUTTON Type Handler Include If Clear Submission Reset Prev

    Next Finish reset() movePrev() moveNext() finished, reset() null canMoveBack() canMoveNext() isLastStep() true true false false Validate & Validation_groups false false true true Customization • handler • include_if • clear_submission • validate • validation_groups ←Button FlowButtonType
  26. @yceruto $builder->add('skip', FlowNextType::class, [ 'clear_submission' => true, 'validate' => false,

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

    'clear_submission' => false, ]); 1 2 3 4 $flow->submit([ 'step_4' => [ 'field' => 'value', ], 'navigator' => [ 'back_to' => 'step_2', ], ]); • handler • include_if • clear_submission • validate • validation_groups ←Button Customization PREVIOUS FlowButtonType
  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', FlowButtonType::class, [ 'validate' => false, 'validation_groups' => false, 'handler' => function (Baggage $data) { $data->items[] = new BaggageItem(); }, ]); } } • Inter-step ←Button 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', FlowButtonType::class, [ 'validate' => false, 'validation_groups' => false, 'handler' => function (Baggage $data) { $data->items[] = new BaggageItem(); }, ]); } } • Inter-step ←Button 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', FlowButtonType::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 ←Button Customization
  31. @yceruto • Inter-step ←Button 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', FlowButtonType::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()]); }, ]); } }