---
title: "Créer des Formulaires Multi-Étapes avec Symfony FormFlow"
excerpt: "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."
publishDate: 2025-11-11T00:00:00.000Z
tags: ["symfony", "formflow", "symfony7-4"]
canonical: "https://yoandev.co/form-flow-symfony"
---

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

- [Annonce officielle Symfony 7.4](https://symfony.com/blog/new-in-symfony-7-4-multi-step-forms)
- [Dépôt de démonstration](https://github.com/yceruto/formflow-demo)

## 🎯 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"`.

```bash
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 :

```javascript
import './bootstrap.js';
```

Votre fichier `assets/app.js` doit contenir uniquement :

```javascript
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) :

```yaml
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 :

```shell
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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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 :

```php
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` :

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

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

```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** :
```php
// 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.

| Aspect | Formulaire classique | FormFlow |
|--------|---------------------|----------|
| **Classe de base** | `AbstractType` | `AbstractFlowType` |
| **Méthode de construction** | `buildForm()` | `buildFormFlow()` |
| **Builder** | `FormBuilderInterface` | `FormFlowBuilderInterface` |
| **Validation complète** | `isValid()` | `isValid() && isFinished()` |
| **Rendu** | Passer `$form` à la vue | Passer `$flow->getStepForm()` |
| **Options DTO** | `data_class` uniquement | `data_class` + `step_property_path` |
| **Groupes de validation** | Déclarés manuellement | Automatiques par étape |
| **Stockage** | Votre 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
