· 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.
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 :
- Informations personnelles : Nom, prénom, préférence de voyage
- Préférences de voyage : Budget, destinations, newsletter
- 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 :
- Séparation des responsabilités : Vos entités restent propres, les DTOs gèrent uniquement la collecte de données
- Validation par étape : Chaque DTO d’étape peut avoir ses propres règles de validation
- Flexibilité : Vous pouvez collecter des données qui ne correspondent pas directement à votre modèle de données
- 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 obligatoireLength: Le nom/prénom doit faire entre 2 et 50 caractèresChoice: La préférence doit être l’une des valeurs autoriséesgroups: ['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 :
destinationsest un tableau : l’utilisateur peut choisir plusieurs destinationsCount(min: 1): Au moins une destination doit être sélectionnéenewslettern’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 :
placeholderrend le formulaire plus user-friendly- Le format
'Label' => 'valeur'dans choices : le label s’affiche, la valeur est envoyée - Pas de
'required' => truecar 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 radioexpanded: true+multiple: true= Checkboxesexpanded: 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(): Retournetruesi on est à la première étapeisLastStep(): Retournetruesi on est à la dernière étapegetCurrentStep(): Retourne le nom de l’étape courantestepIndex: 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 :
- L’ordre des étapes avec
addStep() - Le lien avec le DTO principal via
data_class - La propriété de tracking via
step_property_path
Différences majeures avec un formulaire classique :
- Vous héritez de
AbstractFlowType(pasAbstractType) - Vous implémentez
buildFormFlow()(pasbuildForm()) - Vous utilisez un
FormFlowBuilderInterface(pasFormBuilderInterface)
Le nom que vous donnez à chaque étape dans addStep() doit correspondre :
- Au nom de la propriété dans le DTO principal
- Au groupe de validation
- À 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 :
-
createForm()retourne un FormFlow : Vous créez le flow exactement comme un formulaire classique, mais l’objet retourné implémenteFormFlowInterface -
isFinished()est crucial : En plus deisSubmitted()etisValid(), vous devez vérifierisFinished()pour savoir si l’utilisateur a complété TOUTES les étapes -
getStepForm()pour le rendu : Au lieu de passer directement$flowà la vue, vous appelezgetStepForm()qui retourne le formulaire de l’étape courante -
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 :
- L’utilisateur soumet l’étape courante
handleRequest()traite la soumission- Si valide, Symfony met à jour le DTO et avance à l’étape suivante (ou termine si c’était la dernière)
- Le contrôleur re-rend la page avec la nouvelle étape
- 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 flowform.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 erreursrole="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_stepscontient toutes les étapes - Étape complétée :
step.index < form.vars.cursor.stepIndexaffiche 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 :
- Vous nommez une étape dans
addStep('individual', ...) - Symfony active automatiquement le groupe de validation
['individual'] - Seules les contraintes avec
groups: ['individual']sont validées - 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 validationNextFlowType: Valide l’étape courante puis avanceFinishFlowType: 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
Loading comments...