· 20 min read

Créer des Formulaires Multi-Étapes avec Symfony FormFlow

Symfony 7.4 introduit une fonctionnalité majeure attendue depuis longtemps : les formulaires multi-étapes (FormFlow). Cette nouveauté native permet de diviser de longs formulaires en plusieurs étapes connectées, offrant une meilleure expérience utilisateur et une gestion simplifiée des données complexes.

Symfony 7.4 introduit une fonctionnalité majeure attendue depuis longtemps : les formulaires multi-étapes (FormFlow). Cette nouveauté native permet de diviser de longs formulaires en plusieurs étapes connectées, offrant une meilleure expérience utilisateur et une gestion simplifiée des données complexes.

Introduction

Symfony 7.4 introduit une fonctionnalité majeure attendue depuis longtemps : les formulaires multi-étapes (FormFlow). Cette nouveauté native permet de diviser de longs formulaires en plusieurs étapes connectées, offrant une meilleure expérience utilisateur et une gestion simplifiée des données complexes.

Les formulaires multi-étapes sont particulièrement utiles lorsque vous devez collecter beaucoup d’informations : au lieu de présenter un formulaire intimidant avec de nombreux champs, vous guidez l’utilisateur progressivement à travers plusieurs petites étapes. Cela améliore le taux de complétion et réduit la charge cognitive.

Dans cet article, nous allons créer ensemble un formulaire d’inscription “EasyTravel” en 3 étapes, avec PicoCSS pour un design épuré et moderne.

📚 Ressources officielles

🎯 Notre projet : Formulaire d’inscription EasyTravel

Pour bien comprendre les FormFlow, nous allons construire un cas pratique : un formulaire d’inscription pour une agence de voyage. Ce type de formulaire nécessite plusieurs types d’informations et se prête parfaitement à une approche multi-étapes.

Nous allons créer un formulaire en 3 étapes :

  1. Informations personnelles : Nom, prénom, préférence de voyage
  2. Préférences de voyage : Budget, destinations, newsletter
  3. Confirmation : Validation des CGU

Chaque étape aura sa propre validation, et l’utilisateur pourra naviguer librement entre les étapes déjà complétées.

⚙️ Préparation

Commençons par créer un nouveau projet Symfony.

Je précise --version="next" car au moment où j’écris cet article, la version 7.4 de Symfony est en beta. Quand la version 7.4 sera sortie, vous pourrez faire l’impasse sur --version="next".

symfony new TestFormFlow --version="next" --webapp
cd TestFormFlow
symfony serve -d

Je désactive Turbo : je ne suis pas un grand fan de Turbo, je vous rappel que je passe ma vie à créer des API donc 😅.

Dans assets/app.js, supprimez cette ligne :

import './bootstrap.js';

Votre fichier assets/app.js doit contenir uniquement :

import './styles/app.css';

console.log('This log comes from assets/app.js - welcome to AssetMapper! 🎉');

Selon votre version (en @dev j’ai eu besoin de configurer les routes) vérifiez que les routes définies avec les attributs #[Route] dans le contrôleur sont bien importées dans config/routes.yaml (c’est généralement le cas par défaut) :

controllers:
    resource:
        path: ../src/Controller/
        namespace: App\Controller
    type: attribute

Cette configuration indique à Symfony de scanner tous les fichiers dans src/Controller/ et de charger les routes définies par attributs.

📁 Structure du projet

Avant de commencer, comprenons l’architecture d’un FormFlow. Contrairement à un formulaire classique qui utilise une seule entité, un FormFlow s’organise autour de plusieurs concepts :

  • DTOs (Data Transfer Objects) : Objets légers qui transportent les données entre les étapes
  • FormTypes par étape : Chaque étape a son propre type de formulaire
  • FormFlow principal : Le chef d’orchestre qui assemble toutes les étapes
  • NavigatorType : Gère les boutons de navigation (Précédent/Suivant/Terminer)

Voici l’organisation des fichiers :

src/
├── Controller/
│   └── TravelRegistrationController.php
├── Form/
│   ├── Data/
│   │   ├── TravelRegistrationDto.php (DTO principal)
│   │   └── Step/
│   │       ├── IndividualDto.php
│   │       ├── PreferencesDto.php
│   │       └── ConfirmationDto.php
│   └── Type/
│       ├── TravelRegistrationType.php (FormFlow principal)
│       ├── TravelNavigatorType.php (Navigation)
│       └── Step/
│           ├── IndividualType.php
│           ├── PreferencesType.php
│           └── ConfirmationType.php
templates/
├── base.html.twig
└── travel_registration/
    ├── form.html.twig
    └── success.html.twig

Pour vous simpifier la vie, voici une commande pour créer les répertoires de base :

mkdir -p src/Form/Data/Step src/Form/Type/Step templates/travel_registration

1️⃣ Création des DTOs (Data Transfer Objects)

Pourquoi des DTOs ?

Dans un FormFlow, les DTOs jouent un rôle central. Au lieu d’utiliser directement vos entités Doctrine, vous créez des objets légers dédiés au transfert de données. Cela présente plusieurs avantages :

  1. Séparation des responsabilités : Vos entités restent propres, les DTOs gèrent uniquement la collecte de données
  2. Validation par étape : Chaque DTO d’étape peut avoir ses propres règles de validation
  3. Flexibilité : Vous pouvez collecter des données qui ne correspondent pas directement à votre modèle de données
  4. Session : Les DTOs sont stockés en session entre les étapes, pas besoin de persister en base avant la fin

DTO Principal

Le DTO principal est le conteneur qui regroupe tous les DTOs des étapes. C’est lui qui sera lié au FormFlow et qui transitera entre les différentes étapes via la session.

La propriété currentStep est cruciale : elle indique à Symfony quelle étape afficher à l’utilisateur. Elle doit correspondre exactement aux noms que vous donnerez aux étapes dans le FormFlow.

src/Form/Data/TravelRegistrationDto.php :

<?php

namespace App\Form\Data;

use App\Form\Data\Step\ConfirmationDto;
use App\Form\Data\Step\IndividualDto;
use App\Form\Data\Step\PreferencesDto;
use Symfony\Component\Validator\Constraints as Assert;

class TravelRegistrationDto
{
    public string $currentStep = 'individual';

    #[Assert\Valid(groups: ['individual'])]
    public ?IndividualDto $individual = null;

    #[Assert\Valid(groups: ['preferences'])]
    public ?PreferencesDto $preferences = null;

    #[Assert\Valid(groups: ['confirmation'])]
    public ?ConfirmationDto $confirmation = null;
}

Points clés à comprendre :

  • currentStep = 'individual' : Stocke le nom de l’étape courante (initialisé à la première étape)
  • Groupes de validation : Chaque propriété déclare son groupe (nom de l’étape), permettant une validation progressive
  • #[Assert\Valid] : Déclenche la validation en cascade sur les DTOs enfants
  • Propriétés nullable initialisées à null : ⚠️ Important ! Le FormFlow crée automatiquement les objets quand nécessaire. Ne créez JAMAIS de constructeur pour les initialiser manuellement, cela empêcherait le bon fonctionnement du mapping des données

DTOs des étapes

Chaque étape a son propre DTO qui encapsule les données spécifiques à cette étape. La clé du système de validation progressive réside dans les groupes de validation : notez que toutes les contraintes déclarent le même groupe (groups: ['nomDeLEtape']).

Étape 1 : Informations personnelles

Ce DTO collecte les informations de base de l’utilisateur. Notez que tous les champs utilisent le groupe ['individual'], ce qui signifie que ces validations ne seront exécutées que lors de l’étape 1.

src/Form/Data/Step/IndividualDto.php :

<?php

namespace App\Form\Data\Step;

use Symfony\Component\Validator\Constraints as Assert;

class IndividualDto
{
    #[Assert\NotBlank(groups: ['individual'])]
    #[Assert\Length(min: 2, max: 50, groups: ['individual'])]
    public ?string $firstName = null;

    #[Assert\NotBlank(groups: ['individual'])]
    #[Assert\Length(min: 2, max: 50, groups: ['individual'])]
    public ?string $lastName = null;

    #[Assert\NotBlank(groups: ['individual'])]
    #[Assert\Choice(
        choices: ['aventure', 'detente', 'culture'],
        groups: ['individual']
    )]
    public ?string $travelPreference = null;
}

Explication des contraintes :

  • NotBlank : Le champ est obligatoire
  • Length : Le nom/prénom doit faire entre 2 et 50 caractères
  • Choice : La préférence doit être l’une des valeurs autorisées
  • groups: ['individual'] : Ces validations ne s’appliquent que pendant l’étape 1

Étape 2 : Préférences de voyage

Cette étape illustre la flexibilité des DTOs : le champ destinations est un tableau, et newsletter est un booléen non obligatoire. La contrainte Count garantit qu’au moins une destination est sélectionnée.

src/Form/Data/Step/PreferencesDto.php :

<?php

namespace App\Form\Data\Step;

use Symfony\Component\Validator\Constraints as Assert;

class PreferencesDto
{
    #[Assert\NotBlank(groups: ['preferences'])]
    #[Assert\Choice(
        choices: ['<1000', '1000-3000', '>3000'],
        groups: ['preferences']
    )]
    public ?string $budget = null;

    #[Assert\NotBlank(groups: ['preferences'])]
    #[Assert\Count(min: 1, groups: ['preferences'])]
    public array $destinations = [];

    public bool $newsletter = false;
}

Points importants :

  • destinations est un tableau : l’utilisateur peut choisir plusieurs destinations
  • Count(min: 1) : Au moins une destination doit être sélectionnée
  • newsletter n’a pas de contrainte : c’est un champ optionnel
  • Tous les groupes sont ['preferences'] : validation uniquement à l’étape 2

Étape 3 : Confirmation

L’étape finale utilise une contrainte IsTrue pour s’assurer que l’utilisateur accepte les CGU. C’est un pattern classique pour les formulaires d’inscription.

src/Form/Data/Step/ConfirmationDto.php :

<?php

namespace App\Form\Data\Step;

use Symfony\Component\Validator\Constraints as Assert;

class ConfirmationDto
{
    #[Assert\IsTrue(
        message: 'Vous devez accepter les CGU',
        groups: ['confirmation']
    )]
    public bool $acceptTerms = false;
}

Astuce : IsTrue est parfait pour les cases à cocher obligatoires comme les CGU.

2️⃣ Types de formulaire pour chaque étape

Le rôle des FormTypes dans un FormFlow

Maintenant que nous avons nos DTOs, nous devons créer les formulaires qui permettront de les remplir. Dans un FormFlow, chaque étape a son propre FormType, exactement comme vous créeriez un formulaire Symfony classique.

La différence majeure : ces FormTypes sont autonomes. Chacun ne connaît que son propre DTO d’étape, pas le DTO principal. C’est le FormFlow qui se charge de les assembler.

⚠️ Important : Ne déclarez PAS de groupes de validation dans configureOptions() des FormTypes d’étapes. Les groupes sont automatiquement appliqués par le FormFlow en fonction du nom de l’étape.

Étape 1 : Informations personnelles

Ce formulaire combine différents types de champs : texte simple pour le nom/prénom, et une liste déroulante pour les préférences. L’utilisation de placeholder guide l’utilisateur.

src/Form/Type/Step/IndividualType.php :

<?php

namespace App\Form\Type\Step;

use App\Form\Data\Step\IndividualDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class IndividualType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('firstName', TextType::class, [
                'label' => 'Prénom',
                'attr' => ['placeholder' => 'Votre prénom'],
            ])
            ->add('lastName', TextType::class, [
                'label' => 'Nom',
                'attr' => ['placeholder' => 'Votre nom'],
            ])
            ->add('travelPreference', ChoiceType::class, [
                'label' => 'Préférence de voyage',
                'choices' => [
                    'Aventure' => 'aventure',
                    'Détente' => 'detente',
                    'Culture' => 'culture',
                ],
                'placeholder' => 'Choisissez votre préférence',
            ])
        ;
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => IndividualDto::class,
        ]);
    }
}

Bonnes pratiques :

  • placeholder rend le formulaire plus user-friendly
  • Le format 'Label' => 'valeur' dans choices : le label s’affiche, la valeur est envoyée
  • Pas de 'required' => true car la validation est gérée par le DTO

Étape 2 : Préférences de voyage

Cette étape illustre la puissance des formulaires Symfony : expanded: true avec multiple: true pour les destinations crée automatiquement des checkboxes, tandis que pour le budget, cela crée des boutons radio.

src/Form/Type/Step/PreferencesType.php :

<?php

namespace App\Form\Type\Step;

use App\Form\Data\Step\PreferencesDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class PreferencesType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('budget', ChoiceType::class, [
                'label' => 'Budget',
                'choices' => [
                    'Moins de 1000€' => '<1000',
                    'Entre 1000€ et 3000€' => '1000-3000',
                    'Plus de 3000€' => '>3000',
                ],
                'expanded' => true,
            ])
            ->add('destinations', ChoiceType::class, [
                'label' => 'Destinations favorites',
                'choices' => [
                    'Europe' => 'europe',
                    'Asie' => 'asie',
                    'Amérique du Nord' => 'amerique_nord',
                    'Amérique du Sud' => 'amerique_sud',
                    'Afrique' => 'afrique',
                    'Océanie' => 'oceanie',
                ],
                'expanded' => true,
                'multiple' => true,
            ])
            ->add('newsletter', CheckboxType::class, [
                'label' => 'Je souhaite recevoir la newsletter',
                'required' => false,
            ])
        ;
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => PreferencesDto::class,
        ]);
    }
}

Comprendre expanded et multiple :

  • expanded: true + multiple: false = Boutons radio
  • expanded: true + multiple: true = Checkboxes
  • expanded: false = Liste déroulante (select)
  • Ces options permettent de créer différents types d’interface sans changer le code

Étape 3 : Confirmation

La dernière étape est minimaliste : une simple case à cocher pour les CGU.

src/Form/Type/Step/ConfirmationType.php :

<?php

namespace App\Form\Type\Step;

use App\Form\Data\Step\ConfirmationDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class ConfirmationType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('acceptTerms', CheckboxType::class, [
                'label' => 'J\'accepte les conditions générales',
                'required' => true,
            ])
        ;
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => ConfirmationDto::class,
        ]);
    }
}

3️⃣ NavigatorType : Gestion de la navigation

Comprendre le système de navigation

Un FormFlow ne serait rien sans un système de navigation efficace. Le NavigatorType est un FormType spécial qui gère les boutons “Précédent”, “Suivant” et “Terminer”.

La magie opère grâce à include_if : cette option accepte une fonction qui reçoit un objet FormFlowCursor. Ce curseur connaît la position actuelle dans le flow et permet d’afficher ou masquer les boutons de manière conditionnelle :

  • “Précédent” n’apparaît pas sur la première étape (isFirstStep())
  • “Suivant” n’apparaît pas sur la dernière étape (isLastStep())
  • “Terminer” n’apparaît que sur la dernière étape

Les types de boutons spéciaux (PreviousFlowType, NextFlowType, FinishFlowType) sont fournis par Symfony et gèrent automatiquement la logique de navigation.

src/Form/Type/TravelNavigatorType.php :

<?php

namespace App\Form\Type;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Flow\FormFlowCursor;
use Symfony\Component\Form\Flow\Type\FinishFlowType;
use Symfony\Component\Form\Flow\Type\NextFlowType;
use Symfony\Component\Form\Flow\Type\PreviousFlowType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class TravelNavigatorType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('previous', PreviousFlowType::class, [
                'label' => '← Précédent',
                'include_if' => fn (FormFlowCursor $cursor) => !$cursor->isFirstStep(),
            ])
            ->add('next', NextFlowType::class, [
                'label' => 'Suivant →',
                'include_if' => fn (FormFlowCursor $cursor) => !$cursor->isLastStep(),
            ])
            ->add('finish', FinishFlowType::class, [
                'label' => '✓ Terminer',
                'include_if' => fn (FormFlowCursor $cursor) => $cursor->isLastStep(),
            ])
        ;
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'label' => false,
            'mapped' => false,
        ]);
    }
}

Points clés à retenir :

  • include_if : Fonction de callback qui détermine si le bouton doit être affiché
  • FormFlowCursor : Objet qui contient l’état du flow (étape courante, première/dernière, etc.)
  • mapped: false : Le navigator n’est pas mappé sur le DTO, c’est juste de l’interface utilisateur
  • Types de boutons spéciaux : Chaque type gère automatiquement l’action (avancer, reculer, terminer)

Méthodes utiles du FormFlowCursor :

  • isFirstStep() : Retourne true si on est à la première étape
  • isLastStep() : Retourne true si on est à la dernière étape
  • getCurrentStep() : Retourne le nom de l’étape courante
  • stepIndex : Position numérique de l’étape (commence à 0)

4️⃣ FormFlow principal : TravelRegistrationType

Le chef d’orchestre du FormFlow

Le FormFlow est le composant central qui assemble tout votre formulaire multi-étapes. C’est ici que vous définissez :

  1. L’ordre des étapes avec addStep()
  2. Le lien avec le DTO principal via data_class
  3. La propriété de tracking via step_property_path

Différences majeures avec un formulaire classique :

  • Vous héritez de AbstractFlowType (pas AbstractType)
  • Vous implémentez buildFormFlow() (pas buildForm())
  • Vous utilisez un FormFlowBuilderInterface (pas FormBuilderInterface)

Le nom que vous donnez à chaque étape dans addStep() doit correspondre :

  1. Au nom de la propriété dans le DTO principal
  2. Au groupe de validation
  3. À la valeur initiale de currentStep

C’est ce qui permet au système de savoir quelle partie du DTO valider et quel FormType afficher.

src/Form/Type/TravelRegistrationType.php :

<?php

namespace App\Form\Type;

use App\Form\Data\TravelRegistrationDto;
use App\Form\Type\Step\ConfirmationType;
use App\Form\Type\Step\IndividualType;
use App\Form\Type\Step\PreferencesType;
use Symfony\Component\Form\Flow\AbstractFlowType;
use Symfony\Component\Form\Flow\FormFlowBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class TravelRegistrationType extends AbstractFlowType
{
    public function buildFormFlow(FormFlowBuilderInterface $builder, array $options): void
    {
        // Étape 1 : Informations personnelles
        $builder->addStep('individual', IndividualType::class);

        // Étape 2 : Préférences
        $builder->addStep('preferences', PreferencesType::class);

        // Étape 3 : Confirmation
        $builder->addStep('confirmation', ConfirmationType::class);

        // Ajout du navigateur
        $builder->add('navigator', TravelNavigatorType::class);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => TravelRegistrationDto::class,
            'step_property_path' => 'currentStep',
        ]);
    }
}

Architecture du FormFlow :

  • addStep() : Enregistre une étape du flow (l’ordre = l’ordre d’ajout)
  • Premier paramètre : Nom de l’étape (doit matcher le DTO et le groupe de validation)
  • Deuxième paramètre : Le FormType à utiliser pour cette étape
  • add('navigator') : Ajoute le système de navigation (pas une étape, juste l’UI)
  • step_property_path : Indique quelle propriété du DTO stocke l’étape courante

⚠️ Important : Les noms des étapes ('individual', 'preferences', 'confirmation') doivent être identiques à :

  • Les propriétés dans TravelRegistrationDto
  • Les groupes de validation dans les DTOs
  • La valeur par défaut de currentStep

5️⃣ Contrôleur

Gérer le flow dans le contrôleur

Le contrôleur d’un FormFlow ressemble beaucoup à un contrôleur de formulaire classique, avec quelques particularités importantes à comprendre :

  1. createForm() retourne un FormFlow : Vous créez le flow exactement comme un formulaire classique, mais l’objet retourné implémente FormFlowInterface

  2. isFinished() est crucial : En plus de isSubmitted() et isValid(), vous devez vérifier isFinished() pour savoir si l’utilisateur a complété TOUTES les étapes

  3. getStepForm() pour le rendu : Au lieu de passer directement $flow à la vue, vous appelez getStepForm() qui retourne le formulaire de l’étape courante

  4. Stockage en session : Entre chaque étape, Symfony stocke automatiquement le DTO en session. Vous n’avez rien à faire !

src/Controller/TravelRegistrationController.php :

<?php

namespace App\Controller;

use App\Form\Data\TravelRegistrationDto;
use App\Form\Type\TravelRegistrationType;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\Flow\FormFlowInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

class TravelRegistrationController extends AbstractController
{
    #[Route('/travel-registration', name: 'app_travel_registration')]
    public function index(Request $request): Response
    {
        $travelData = new TravelRegistrationDto();

        /** @var FormFlowInterface $flow */
        $flow = $this->createForm(TravelRegistrationType::class, $travelData)
            ->handleRequest($request);

        if ($flow->isSubmitted() && $flow->isValid() && $flow->isFinished()) {
            // Stocker les données en session pour la page de succès
            $request->getSession()->set('travel_registration_data', $flow->getData());

            return $this->redirectToRoute('app_travel_registration_success');
        }

        return $this->render('travel_registration/form.html.twig', [
            'form' => $flow->getStepForm(),
            'currentStep' => $travelData->currentStep,
            'data' => $travelData,
        ]);
    }

    #[Route('/travel-registration/success', name: 'app_travel_registration_success')]
    public function success(Request $request): Response
    {
        $data = $request->getSession()->get('travel_registration_data');

        if (!$data) {
            return $this->redirectToRoute('app_travel_registration');
        }

        $request->getSession()->remove('travel_registration_data');

        return $this->render('travel_registration/success.html.twig', [
            'data' => $data,
        ]);
    }
}

Cycle de vie d’une soumission :

  1. L’utilisateur soumet l’étape courante
  2. handleRequest() traite la soumission
  3. Si valide, Symfony met à jour le DTO et avance à l’étape suivante (ou termine si c’était la dernière)
  4. Le contrôleur re-rend la page avec la nouvelle étape
  5. Quand isFinished() est vrai, vous pouvez traiter les données complètes

Astuce de persistence : Dans un vrai projet, c’est ici que vous transformeriez le DTO en entités Doctrine et les persisterez en base de données :

if ($flow->isSubmitted() && $flow->isValid() && $flow->isFinished()) {
    // Créer vos entités à partir du DTO
    $user = new User();
    $user->setFirstName($travelData->individual->firstName);
    // ...
    
    // Persister en base
    $entityManager->persist($user);
    $entityManager->flush();
    
    // Rediriger
    return $this->redirectToRoute('app_travel_registration_success');
}

6️⃣ Templates Twig avec PicoCSS

Comprendre le rendu des FormFlow

Les templates pour un FormFlow sont légèrement différents d’un formulaire classique. Le formulaire passé à la vue ($flow->getStepForm()) contient des variables spéciales dans form.vars :

  • form.vars.visible_steps : Liste de toutes les étapes du flow
  • form.vars.cursor : Objet curseur avec l’état actuel (currentStep, stepIndex, etc.)
  • form.vars.cursor.currentStep : Nom de l’étape courante

Ces informations permettent de créer des barres de progression dynamiques et des indicateurs visuels de l’avancement.

Template de base avec PicoCSS

PicoCSS est un framework CSS “sans classe” qui style automatiquement les éléments HTML sémantiques. Cela signifie que vos <article>, <nav>, <button>, <input>, etc. seront automatiquement beaux sans ajouter de classes CSS.

Le template de base est extrêmement simple : nous incluons PicoCSS via CDN et laissons le framework s’occuper de tout le styling.

templates/base.html.twig :

<!DOCTYPE html>
<html lang="fr" data-theme="light">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>{% block title %}Welcome!{% endblock %}</title>
        <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
        <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>✈️</text></svg>">
        {% block stylesheets %}{% endblock %}
        {% block javascripts %}
            {% block importmap %}{{ importmap('app') }}{% endblock %}
        {% endblock %}
    </head>
    <body>
        <main class="container">
            {% block body %}{% endblock %}
        </main>
    </body>
</html>

Points clés :

  • data-theme="light" : Force le thème clair de PicoCSS
  • .container : Classe PicoCSS qui centre le contenu avec un max-width responsive
  • Pas de CSS personnalisé : Tout est géré par PicoCSS !
  • L’icône avion (✈️) dans le favicon ajoute une touche sympathique

Template du formulaire avec barre de progression

Le template du formulaire exploite les éléments HTML sémantiques que PicoCSS style automatiquement :

  • <article> : Conteneur principal avec padding et background automatiques
  • <nav><ul> : Navigation stylée automatiquement en horizontal
  • <mark> : Pour mettre en évidence les erreurs
  • role="group" : Pour grouper les boutons de navigation

templates/travel_registration/form.html.twig :

{% extends 'base.html.twig' %}

{% block title %}Inscription EasyTravel{% endblock %}

{% block body %}
<article>
    <header>
        <h1>✈️ EasyTravel</h1>
        <p>Formulaire d'inscription multi-étapes</p>
    </header>

    {# Barre de progression simple #}
    <nav>
        <ul>
            {% for step in form.vars.visible_steps %}
                <li>
                    <strong>
                        {% if step.index < form.vars.cursor.stepIndex %}✓{% else %}{{ loop.index }}{% endif %}
                    </strong>
                    {% if step.name == 'individual' %}Profil
                    {% elseif step.name == 'preferences' %}Préférences
                    {% elseif step.name == 'confirmation' %}Validation
                    {% endif %}
                </li>
            {% endfor %}
        </ul>
    </nav>

    {% set current_step_title = form.vars.cursor.currentStep %}
    {% if current_step_title == 'individual' %}
        <h2>👤 Vos informations personnelles</h2>
    {% elseif current_step_title == 'preferences' %}
        <h2>🌍 Vos préférences de voyage</h2>
    {% elseif current_step_title == 'confirmation' %}
        <h2>✅ Dernière étape !</h2>
    {% endif %}

    {{ form_start(form) }}
        {% set current_step = form.vars.cursor.currentStep %}
        
        {% if form.vars.errors|length > 0 %}
            <mark>
                <strong>⚠️ Erreurs détectées</strong>
                <ul>
                    {% for error in form.vars.errors %}
                        <li>{{ error.message }}</li>
                    {% endfor %}
                </ul>
            </mark>
        {% endif %}

        {{ form_widget(form[current_step]) }}

        <div role="group">
            {% if form.navigator is defined %}
                {% if form.navigator.previous is defined %}
                    {{ form_widget(form.navigator.previous, {'attr': {'class': 'secondary'}, 'label': '← Précédent'}) }}
                {% endif %}
                {% if form.navigator.next is defined %}
                    {{ form_widget(form.navigator.next, {'label': 'Suivant →'}) }}
                {% endif %}
                {% if form.navigator.finish is defined %}
                    {{ form_widget(form.navigator.finish, {'label': '✓ Terminer'}) }}
                {% endif %}
            {% endif %}
        </div>
    {{ form_end(form) }}
</article>
{% endblock %}

Éléments clés du template :

  • Barre de progression : form.vars.visible_steps contient toutes les étapes
  • Étape complétée : step.index < form.vars.cursor.stepIndex affiche un ✓
  • Titre dynamique : Change selon l’étape courante
  • Affichage des erreurs : <mark> met en évidence les erreurs de validation
  • Formulaire de l’étape courante : form[current_step] affiche uniquement les champs de l’étape actuelle
  • Navigation conditionnelle : Les boutons apparaissent/disparaissent automatiquement

Pourquoi form[current_step] ? : Au lieu de rendre tout le formulaire, on ne rend que la partie correspondant à l’étape courante. C’est ce qui crée l’effet multi-étapes !

Page de succès

La page de succès récapitule toutes les données collectées dans un format agréable à lire.

templates/travel_registration/success.html.twig :

{% extends 'base.html.twig' %}

{% block title %}Inscription réussie{% endblock %}

{% block body %}
<article>
    <header>
        <h1>🎉 Bienvenue sur EasyTravel !</h1>
        <p>Votre inscription a été confirmée</p>
    </header>

    <section>
        <h2>📋 Récapitulatif</h2>

        <h3>Informations personnelles</h3>
        <ul>
            <li><strong>Nom :</strong> {{ data.individual.firstName }} {{ data.individual.lastName }}</li>
            <li><strong>Préférence :</strong> 
                {% if data.individual.travelPreference == 'aventure' %}🏔️ Aventure
                {% elseif data.individual.travelPreference == 'detente' %}🏖️ Détente
                {% else %}🏛️ Culture
                {% endif %}
            </li>
        </ul>

        <h3>Préférences de voyage</h3>
        <ul>
            <li><strong>Budget :</strong> 
                {% if data.preferences.budget == '<1000' %}💰 Moins de 1000€
                {% elseif data.preferences.budget == '1000-3000' %}💰💰 Entre 1000€ et 3000€
                {% else %}💰💰💰 Plus de 3000€
                {% endif %}
            </li>
            <li><strong>Newsletter :</strong> {{ data.preferences.newsletter ? '✅ Inscrit' : '❌ Non inscrit' }}</li>
            {% if data.preferences.destinations|length > 0 %}
                <li><strong>Destinations :</strong>
                    <ul>
                        {% for destination in data.preferences.destinations %}
                            <li>{{ destination|replace({'_': ' '})|capitalize }}</li>
                        {% endfor %}
                    </ul>
                </li>
            {% endif %}
        </ul>
    </section>

    <footer>
        <a href="{{ path('app_travel_registration') }}" role="button">
            🏠 Nouvelle inscription
        </a>
    </footer>
</article>
{% endblock %}

Astuce design : L’utilisation d’emojis (✈️, 🏔️, 💰, ✅) ajoute de la personnalité sans nécessiter d’images.

📝 Concepts clés à retenir

1. Validation par groupes : Le cœur du système

La validation par groupes est le mécanisme qui permet au FormFlow de valider progressivement les données. Sans cela, Symfony essaierait de valider TOUTES les données à chaque étape, ce qui échouerait constamment.

Comment ça marche :

  1. Vous nommez une étape dans addStep('individual', ...)
  2. Symfony active automatiquement le groupe de validation ['individual']
  3. Seules les contraintes avec groups: ['individual'] sont validées
  4. Les autres contraintes sont ignorées pour cette étape

Exemple concret :

// Dans IndividualDto
#[Assert\NotBlank(groups: ['individual'])]  // ✅ Validé à l'étape 1
public ?string $firstName = null;

// Dans PreferencesDto
#[Assert\NotBlank(groups: ['preferences'])]  // ⏭️ Ignoré à l'étape 1
public ?string $budget = null;

La validation Symfony est automatiquement appliquée selon le nom de l’étape :

  • Étape individual → groupe ['individual']
  • Étape preferences → groupe ['preferences']
  • Étape confirmation → groupe ['confirmation']

2. Navigation conditionnelle : L’UX au service du flow

Le système de navigation conditionnelle rend les FormFlow intuitifs. Au lieu d’afficher toujours les trois boutons, Symfony adapte l’interface selon le contexte.

Logique de navigation intelligente :

  • Première étape : Seulement “Suivant” (pas de retour possible)
  • Étapes intermédiaires : “Précédent” et “Suivant”
  • Dernière étape : “Précédent” et “Terminer” (pas de suivant)

3. Types de boutons : Chaque action a son type

Symfony fournit des types de boutons spécialisés pour le FormFlow. Chacun a un comportement prédéfini :

  • PreviousFlowType : Retourne à l’étape précédente sans validation
  • NextFlowType : Valide l’étape courante puis avance
  • FinishFlowType : Valide l’étape courante et marque le flow comme terminé

Important : NextFlowType et FinishFlowType déclenchent la validation, mais PreviousFlowType ne valide pas. Cela permet à l’utilisateur de revenir en arrière pour corriger des données sans être bloqué par des validations.

4. PicoCSS : CSS sans classe

PicoCSS est parfait pour les FormFlow car il style automatiquement les éléments HTML sémantiques. Pas besoin d’ajouter des classes, il suffit d’utiliser les bonnes balises :

  • <article> : Conteneur avec card styling automatique
  • <nav><ul> : Navigation horizontale automatique
  • <mark> : Mise en évidence automatique (parfait pour les erreurs)
  • role="button" sur <a> : Style le lien comme un bouton

Avantage : Code HTML plus propre, plus sémantique, et moins de CSS à maintenir !

5. Différences avec les formulaires classiques

Comprendre ce qui change entre un formulaire classique et un FormFlow vous aidera à éviter les pièges.

AspectFormulaire classiqueFormFlow
Classe de baseAbstractTypeAbstractFlowType
Méthode de constructionbuildForm()buildFormFlow()
BuilderFormBuilderInterfaceFormFlowBuilderInterface
Validation complèteisValid()isValid() && isFinished()
RenduPasser $form à la vuePasser $flow->getStepForm()
Options DTOdata_class uniquementdata_class + step_property_path
Groupes de validationDéclarés manuellementAutomatiques par étape
StockageVotre responsabilitéAutomatique en session

Piège courant : Oublier de vérifier isFinished() dans le contrôleur. Sans ça, votre code de traitement s’exécuterait après chaque étape, pas seulement à la fin !

✅ Conclusion

Les FormFlow de Symfony 7.4 apportent une solution native et élégante pour créer des formulaires multi-étapes. En combinant avec PicoCSS, vous obtenez un design moderne sans écrire de CSS personnalisé.

Ce que vous devez retenir :

Architecture :

  • DTOs pour transporter les données (légers, dédiés au formulaire)
  • Groupes de validation pour valider progressivement
  • FormTypes par étape pour isoler la logique
  • FormFlow pour orchestrer le tout

Validation :

  • Chaque étape = un groupe de validation
  • Symfony active automatiquement le bon groupe
  • Les DTOs enfants sont validés en cascade avec #[Assert\Valid]

Navigation :

  • Types de boutons spécialisés (Previous/Next/Finish)
  • Affichage conditionnel avec include_if
  • Retour en arrière sans validation

Persistance :

  • Données stockées automatiquement en session entre les étapes
  • Persistence en base uniquement à la fin (quand isFinished())
  • Nettoyage de session après traitement
Back to Blog

Comments (0)

Loading comments...

Leave a Comment