Architecture du Form Component
Un formulaire Symfony est une hiérarchie d'objets — chaque champ est lui-même un formulaire.
Le Form Component Symfony repose sur un principe simple mais puissant : chaque champ est lui-même un formulaire. Un formulaire racine contient des enfants, qui peuvent à leur tour contenir des enfants. Tout objet de cette hiérarchie implémente FormInterface.
Chaque nœud de l'arbre expose
add(), get(), has(), remove(), all() pour naviguer entre les enfants, et getParent(), getRoot(), isRoot() pour remonter vers la racine.
AbstractType — la brique de base
Chaque type de champ étend AbstractType et peut surcharger quatre méthodes :
| Méthode | Rôle | Appelée quand |
|---|---|---|
buildForm() | Ajouter des enfants, attacher des events/transformers | Construction de l'arbre |
configureOptions() | Déclarer les options acceptées via OptionsResolver | Résolution des options |
buildView() | Passer des variables au template Twig | Rendu du formulaire |
finishView() | Accéder aux vues des enfants après leur construction | Post-rendu |
StringUtil::fqcnToBlockPrefix(). ProductType → préfixe product. Il détermine les noms des blocs Twig utilisés pour le rendu.
FormFactory
// Créer un formulaire depuis un type $form = $factory->create(ProductType::class, $product); // Créer avec un nom explicite (pour les APIs sans <form>) $form = $factory->createNamed('product', ProductType::class, $product); // Obtenir le builder pour personnaliser avant construction $builder = $factory->createBuilder(ProductType::class, $product);
Les deux flux fondamentaux
Le Form Component opère selon deux flux distincts. Le flux setData prépare l'affichage, le flux submit traite la soumission.
Flux setData — de la DB vers la vue
À l'étape PRE_SET_DATA, il est encore possible d'ajouter ou de supprimer des champs — c'est là qu'on adapte le formulaire selon les données existantes. Après POST_SET_DATA, le formulaire est prêt pour l'affichage.
Flux submit — de la requête vers le modèle
À PRE_SUBMIT, les données sont encore brutes (chaînes de la requête HTTP) — on peut les modifier ou adapter la structure du formulaire. Après POST_SUBMIT, le modèle est reconstruit et isValid() peut être appelé.
Simulateur — Les deux flux
Clique sur un bouton pour visualiser les étapes séquentielles du flux correspondant.
Types built-in avancés
Chaque type built-in a des options qui le distinguent — connaître les options avancées évite de recréer des types existants.
Symfony livre une trentaine de types prêts à l'emploi. Chacun a des options spécifiques qui le distinguent — les connaître évite d'écrire un type custom inutile.
PasswordType
Le champ est rendu vide à chaque affichage même si une valeur existe — comportement attendu pour la sécurité. Mettre à
false pour pré-remplir (rarement souhaitable).
$builder->add('password', PasswordType::class, [ 'always_empty' => false, // pré-remplir si données existantes 'trim' => false, // ne pas supprimer les espaces (défaut) ]);
NumberType et MoneyType
| Option | Type | Description |
|---|---|---|
scale | int | Nombre de décimales (défaut : selon locale) |
grouping | bool | Séparateurs de milliers (défaut : false) |
rounding_mode | int | Constante \NumberFormatter::ROUND_* |
html5 | bool | Render en <input type="number"> |
currency | string | MoneyType : code ISO 4217 (EUR, USD…) |
divisor | int | MoneyType : divise/multiplie pour convertir centimes↔euros |
// Prix stocké en centimes, affiché en euros $builder->add('price', MoneyType::class, [ 'currency' => 'EUR', 'divisor' => 100, // stocké en centimes → affiché en euros 'scale' => 2, ]);
DateType
L'option widget contrôle le rendu HTML, l'option input contrôle le type PHP attendu en entrée/sortie.
| widget | Rendu HTML |
|---|---|
choice | 3 selects jour/mois/année (défaut) |
single_text | <input type="date"> |
text | 3 inputs texte séparés |
| input | Type PHP attendu |
|---|---|
datetime | \DateTime |
datetime_immutable | \DateTimeImmutable |
string | chaîne format Y-m-d |
timestamp | entier Unix |
array | tableau ['year'=>…, 'month'=>…, 'day'=>…] |
ChoiceType — options avancées
$builder->add('status', ChoiceType::class, [ 'choices' => ['Actif' => 'active', 'Inactif' => 'inactive'], 'expanded' => false, // false = <select>, true = radio/checkbox 'multiple' => false, // true = checkboxes ou <select multiple> 'placeholder' => 'Choisir…', 'choice_label' => fn($choice, $key, $value) => $key, 'choice_value' => fn($choice) => $choice->getId(), 'choice_attr' => fn($choice) => ['data-icon' => $choice->getIcon()], 'group_by' => fn($choice) => $choice->getCategory(), 'preferred_choices' => ['active'], ]);
EnumType (PHP 8.1+)
Depuis Symfony 6.1, EnumType sait gérer les enums PHP natifs directement :
$builder->add('level', EnumType::class, [ 'class' => CourseLevel::class, 'choice_label' => fn(CourseLevel $level) => $level->getLabel(), ]);
EntityType
EntityType étend ChoiceType. Il charge les entités Doctrine automatiquement. Option query_builder pour filtrer, choice_label pour le texte affiché, choice_value pour la valeur soumise (défaut : id).
$builder->add('category', EntityType::class, [ 'class' => Category::class, 'query_builder' => fn(CategoryRepository $repo) => $repo->createQueryBuilder('c') ->where('c.active = true') ->orderBy('c.name', 'ASC'), 'choice_label' => 'name', ]);
Options de configuration
Les options sont le langage de configuration des types — comprendre data_class, mapped, inherit_data et by_reference évite les bugs silencieux.
Les options sont déclarées dans configureOptions() via OptionsResolver. Certaines ont un impact fort sur le mapping des données — les mal comprendre génère des bugs silencieux.
data_class et empty_data
data_class lie le formulaire à une classe PHP. Sans elle, le formulaire retourne un tableau associatif.
public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ 'data_class' => Product::class, 'empty_data' => fn(FormInterface $form) => new Product( $form->get('name')->getData(), $form->get('price')->getData(), ), ]); }
mapped: false
Un champ avec mapped: false n'est pas lié au modèle. Sa valeur n'est ni lue depuis les données ni écrite dedans. Utile pour les champs d'interface (checkbox CGV, captcha).
$builder->add('acceptTerms', CheckboxType::class, [ 'mapped' => false, 'constraints' => [new IsTrue()], ]); // Récupérer la valeur manuellement : $accepted = $form->get('acceptTerms')->getData();
inherit_data: true
Un formulaire avec inherit_data: true n'a pas de mapping propre — il hérite les données du formulaire parent. Pratique pour factoriser des groupes de champs réutilisables (ex : champs adresse).
// AddressType.php public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults(['inherit_data' => true]); } // Dans le formulaire parent : $builder->add('shippingAddress', AddressType::class); // Les champs de AddressType lisent/écrivent directement dans $order
data_class mappe vers un sous-objet. inherit_data mappe vers le même objet que le parent. Avec inherit_data, il n'y a pas de sous-objet intermédiaire.
by_reference: false
Par défaut, si data_class est défini, Symfony modifie l'objet par référence (appel des setters sur l'objet existant). Avec by_reference: false, il remplace l'objet entier via le setter du champ parent.
$builder->add('tags', CollectionType::class, [ 'entry_type' => TagType::class, 'by_reference' => false, // appelle $product->setTags($newCollection) ]);
getter et setter (Symfony 6.3+)
Pour un champ dont le nom ne correspond pas à un accesseur standard, on peut fournir des callables :
$builder->add('fullName', TextType::class, [ 'getter' => fn(User $user, FormInterface $form) => $user->getFirstName() . ' ' . $user->getLastName(), 'setter' => fn(User &$user, $value, FormInterface $form) => [ $user->setFirstName(explode(' ', $value)[0]), $user->setLastName(explode(' ', $value)[1] ?? ''), ], ]);
error_bubbling
error_bubbling est false — les erreurs restent sur le champ. Pour les types simples non-compound, il est true — les erreurs remontent vers le parent. Surcharger explicitement pour contrôler le comportement.
invalid_message
Affiché quand un DataTransformer lance une TransformationFailedException. Remplace le message générique "Cette valeur n'est pas valide." par un message spécifique au champ.
$builder->add('birthDate', DateType::class, [ 'widget' => 'single_text', 'invalid_message' => 'Format attendu : AAAA-MM-JJ.', ]);
invalid_message n'est utilisée que quand la transformation échoue (valeur non convertible). Pour les violations de contrainte normale (NotBlank, Length…), c'est le message de la contrainte qui s'affiche.
CollectionType et formulaires imbriqués
CollectionType gère les listes dynamiques de sous-formulaires — prototype, allow_add et allow_delete permettent les listes modifiables côté JS.
CollectionType gère une liste de sous-formulaires du même type. Chaque élément de la liste est un formulaire enfant. C'est le mécanisme standard pour les relations one-to-many dans Symfony.
Configuration de base
$builder->add('phones', CollectionType::class, [ 'entry_type' => PhoneType::class, 'entry_options' => ['label' => false], 'allow_add' => true, 'allow_delete' => true, 'delete_empty' => true, // supprime les entrées vides à la soumission 'keep_as_list' => true, // re-indexe les clés après suppression 'by_reference' => false, // obligatoire pour les collections Doctrine ]);
Le prototype — ajout dynamique côté JS
Avec allow_add: true, Symfony génère un attribut data-prototype sur le conteneur. Ce prototype contient le HTML d'une nouvelle entrée avec __name__ comme placeholder d'index.
{# Twig #} <div id="phones-container" data-prototype="{{ form_widget(form.phones.vars.prototype)|e('html_attr') }}"> {% for phoneForm in form.phones %} {{ form_row(phoneForm) }} {% endfor %} </div> <button type="button" id="add-phone">Ajouter</button>
Pattern JS pour ajouter/supprimer
const container = document.getElementById('phones-container'); let index = container.children.length; // Ajouter une entrée document.getElementById('add-phone').addEventListener('click', () => { const prototype = container.dataset.prototype; const newEntry = prototype.replace(/__name__/g, index++); const div = document.createElement('div'); div.innerHTML = newEntry; // Bouton supprimer const del = document.createElement('button'); del.textContent = 'Supprimer'; del.addEventListener('click', () => container.removeChild(div)); div.appendChild(del); container.appendChild(div); });
Par défaut
__name__. Peut être changé via l'option prototype_name si le placeholder par défaut entre en conflit avec le HTML du sous-formulaire.
'delete_empty' => fn($value) => empty($value->getPhone()) — pour définir précisément ce qu'est une entrée "vide" à ignorer.
DataTransformer
Un DataTransformer traduit les données entre la couche modèle et la couche vue — nécessaire quand le format affiché diffère du format stocké.
Imagine un champ de prix : l'entité stocke 14990 (en centimes), mais l'utilisateur voit 149.90 et tape un nombre décimal. Quelque chose doit faire cette traduction dans les deux sens — c'est le rôle du DataTransformer.
Deux méthodes symétriques :
transform(mixed $value): mixed — model data → view data (affichage)reverseTransform(mixed $value): mixed — view data → model data (soumission)
Exemple 1 — Entité Tag vers chaîne
final class TagToStringTransformer implements DataTransformerInterface { public function __construct(private readonly TagRepository $tags) {} /** @param Tag|null $value */ public function transform(mixed $value): mixed { return $value?->getName() ?? ''; } /** @return Tag|null */ public function reverseTransform(mixed $value): mixed { if (empty($value)) { return null; } $tag = $this->tags->findOneByName($value); if (!$tag) { throw new TransformationFailedException( "Tag \"$value\" introuvable" ); } return $tag; } } // Dans buildForm : $builder->get('tag')->addViewTransformer(new TagToStringTransformer($this->tags));
addViewTransformer vs addModelTransformer
Il existe une chaîne de trois représentations : model → normalized → view.
| Méthode | Position dans la chaîne | Cas d'usage |
|---|---|---|
addViewTransformer() | normalized → view | Le plus courant. Format d'affichage. |
addModelTransformer() | model → normalized | Normalisation interne (ex : tableau → objet). |
transform() ils s'appliquent dans l'ordre d'ajout, en reverseTransform() dans l'ordre inverse.
TransformationFailedException
Si reverseTransform() ne peut pas convertir la valeur soumise, il doit lancer TransformationFailedException. Le formulaire passe alors en état isSynchronized() === false et l'invalid_message du champ est affichée à l'utilisateur.
throw new TransformationFailedException( 'Valeur invalide', 0, // code $previous // cause (utile pour le débogage) );
transform(14990) → '149.90'reverseTransform('149.90') → 14990Si
reverseTransform('abc') → TransformationFailedException
DataMapper
Le DataMapper contrôle comment les données du formulaire parent arrivent dans les champs enfants — et comment ils remontent.
Quand un formulaire possède un data_class, Symfony utilise un DataMapper pour distribuer les données du formulaire parent vers ses enfants, et les récupérer après soumission. Par défaut, c'est PropertyAccessor qui s'en charge.
mapDataToForms(mixed $viewData, \Traversable $forms): voidAppelé lors de
setData() — distribue les données aux champs enfants.mapFormsToData(\Traversable $forms, mixed &$viewData): voidAppelé lors de
submit() — reconstruit les données depuis les champs.
Cas d'usage — formulaire sur un tableau
Le DataMapper par défaut utilise PropertyAccess et nécessite des getters/setters. Pour un tableau associatif, il faut un mapper custom :
final class ArrayDataMapper implements DataMapperInterface { public function mapDataToForms(mixed $viewData, \Traversable $forms): void { foreach ($forms as $name => $form) { $form->setData($viewData[$name] ?? null); } } public function mapFormsToData(\Traversable $forms, mixed &$viewData): void { foreach ($forms as $name => $form) { $viewData[$name] = $form->getData(); } } }
// Dans buildForm() : public function buildForm(FormBuilderInterface $builder, array $options): void { $builder->setDataMapper(new ArrayDataMapper()); $builder->add('name', TextType::class); $builder->add('email', EmailType::class); }
Mappers built-in
| Mapper | Utilisé par | Comportement |
|---|---|---|
DataMapper | Formulaires avec data_class | PropertyAccessor (getters/setters) |
PropertyPathMapper | Champs simples | Chemin de propriété configuré |
CheckboxListMapper | ChoiceType multiple expanded | Tableau de valeurs → checkboxes |
RadioListMapper | ChoiceType single expanded | Valeur unique → radio sélectionné |
Cas d'usage — construction complexe
Pour un DTO immuable (toutes les valeurs passées au constructeur), empty_data en Closure est souvent suffisant. Mais pour une logique plus élaborée (calculs, sous-objets), un DataMapper custom offre plus de contrôle :
public function mapFormsToData(\Traversable $forms, mixed &$viewData): void { $forms = iterator_to_array($forms); $viewData = new CreateProductCommand( name: $forms['name']->getData(), price: (int)($forms['price']->getData() * 100), ); }
Events de formulaire
Les événements de formulaire permettent de modifier les champs et les données dynamiquement — c'est le mécanisme des formulaires conditionnels.
Les événements sont le mécanisme qui permet aux formulaires d'être dynamiques : ajouter ou supprimer des champs selon les données existantes, modifier les données brutes avant leur traitement, ou déclencher une action après validation.
Les cinq événements
| Événement | Déclenchement | Modifier ? | Usage typique |
|---|---|---|---|
PRE_SET_DATA | Avant que les données soient appliquées | Oui | Ajouter/supprimer des champs selon les données |
POST_SET_DATA | Après application des données | Non | Lecture des données initialisées |
PRE_SUBMIT | Avant synchronisation des enfants | Oui | Modifier données brutes, adapter structure |
SUBMIT | Après synchronisation enfants, avant parent | Oui (limité) | Accès aux données synchronisées |
POST_SUBMIT | Après tous les enfants + reverse-transform | Non | Envoyer email, log, effets de bord |
BadMethodCallException — POST_SUBMIT est en lecture seule.
Cas d'usage — formulaire Pays → Régions
Le cas le plus courant : un champ "Région" dont les options dépendent du champ "Pays". Il faut intervenir à deux moments :
- PRE_SET_DATA : au chargement, adapter le champ Région selon le pays de l'entité
- PRE_SUBMIT : à la soumission, adapter le champ Région selon le pays soumis (données brutes)
public function buildForm(FormBuilderInterface $builder, array $options): void { $builder->add('country', ChoiceType::class, [ 'choices' => ['France' => 'FR', 'Allemagne' => 'DE', 'Espagne' => 'ES'], ]); // Closure qui ajoute le champ "region" selon le pays $addRegionField = function (FormInterface $form, ?string $country) { $regions = match ($country) { 'FR' => ['Île-de-France' => 'idf', 'Bretagne' => 'bzh', 'Occitanie' => 'occ'], 'DE' => ['Bavière' => 'by', 'Berlin' => 'be', 'Rhénanie' => 'nrw'], 'ES' => ['Catalogne' => 'cat', 'Madrid' => 'mad', 'Andalousie' => 'and'], default => [], }; $form->add('region', ChoiceType::class, ['choices' => $regions]); }; // Au chargement : PRE_SET_DATA reçoit l'entité $builder->addEventListener( FormEvents::PRE_SET_DATA, function (PreSetDataEvent $event) use ($addRegionField) { $data = $event->getData(); $country = $data?->getCountry(); $addRegionField($event->getForm(), $country); } ); // À la soumission : PRE_SUBMIT reçoit les données brutes (tableau) $builder->addEventListener( FormEvents::PRE_SUBMIT, function (PreSubmitEvent $event) use ($addRegionField) { $data = $event->getData(); $country = $data['country'] ?? null; $addRegionField($event->getForm(), $country); } ); }
PRE_SET_DATA seul ne suffit pas : lors de la soumission, le formulaire repart de zéro avec les données brutes. Si on n'ajoute pas le champ region à PRE_SUBMIT, Symfony ne sait pas qu'il existe et ignore la valeur soumise.
Simulateur — Formulaire dynamique
Change le pays pour voir les régions se mettre à jour — comme le ferait un listener PRE_SET_DATA + PRE_SUBMIT.
FormTypeExtension
Une extension de type ajoute des options ou des comportements à tous les formulaires d'un type donné — sans modifier les types eux-mêmes.
Une FormTypeExtension augmente un type existant sans le modifier. Elle s'applique à toutes les instances du type ciblé dans l'application — c'est le pattern ouvert/fermé appliqué aux formulaires.
Quatre méthodes :
buildForm(), configureOptions(), buildView(), finishView() — les mêmes qu'AbstractType.Une méthode statique supplémentaire :
getExtendedTypes(): iterable — déclare les types à étendre.
Exemple 1 — Attribut data-* automatique sur tous les TextType
final class AutocompleteExtension extends AbstractTypeExtension { public static function getExtendedTypes(): iterable { yield TextType::class; } public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefined('autocomplete_url'); $resolver->setAllowedTypes('autocomplete_url', ['string', 'null']); $resolver->setDefault('autocomplete_url', null); } public function buildView(FormView $view, FormInterface $form, array $options): void { if ($options['autocomplete_url'] !== null) { $view->vars['attr']['data-autocomplete'] = $options['autocomplete_url']; } } }
Désormais, tout TextType peut accepter l'option autocomplete_url :
$builder->add('city', TextType::class, [ 'autocomplete_url' => '/api/cities', ]);
Exemple 2 — Extension qui cible tous les types
public static function getExtendedTypes(): iterable { yield FormType::class; // s'applique à TOUS les types }
AbstractTypeExtension — elle implémente l'interface avec des méthodes vides, tu n'overrides que ce dont tu as besoin.
Enregistrement (Symfony autowire)
Avec autoconfigure: true dans les services, Symfony détecte automatiquement les extensions via l'interface. Aucune config supplémentaire n'est nécessaire.
| Cas d'usage | Extension |
|---|---|
| Attribut HTML sur tous les TextType | getExtendedTypes(): yield TextType::class |
| Option globale sur tous les formulaires | getExtendedTypes(): yield FormType::class |
| Comportement CSRF personnalisé | getExtendedTypes(): yield FormType::class |
| Icône d'aide sur tous les champs | getExtendedTypes(): yield FormType::class |
Validation et gestion des erreurs
La validation Symfony se branche sur le Form Component via l'option constraints — les erreurs suivent la hiérarchie des formulaires.
Le Form Component ne valide pas lui-même — il délègue au composant Validator via le ValidationListener (branché sur POST_SUBMIT), qui applique la contrainte Form résolue par FormValidator. La validation est déclenchée lors du POST_SUBMIT automatiquement si la configuration du formulaire l'active.
Déclencher la validation
// Dans le contrôleur $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { // données validées }
L'option constraints permet d'ajouter des contraintes de validation directement dans le formulaire :
$builder->add('email', EmailType::class, [ 'constraints' => [ new NotBlank(), new Email(['mode' => 'html5']), ], ]);
Lire les erreurs
// Erreurs de la racine seulement $errors = $form->getErrors(); // Toutes les erreurs (récursif) dans une liste plate $allErrors = $form->getErrors(deep: true, flatten: true); foreach ($allErrors as $error) { // $error est un FormError echo $error->getMessage(); echo $error->getOrigin()->getName(); // champ source }
error_bubbling
| Valeur | Comportement |
|---|---|
false (défaut compound) | L'erreur reste sur le champ concerné |
true (défaut non-compound) | L'erreur remonte vers le formulaire parent |
$builder->add('tags', CollectionType::class, [ 'error_bubbling' => false, // les erreurs sur les tags restent sur ce champ ]);
isSynchronized et TransformationFailedException
Quand un DataTransformer lance TransformationFailedException, le champ passe en état "non synchronisé". isValid() retourne false même si aucune contrainte Validator n'est violée.
if (!$form->get('date')->isSynchronized()) { $failure = $form->get('date')->getTransformationFailure(); // $failure est une TransformationFailedException }
getExtraData
Les champs soumis qui n'existent pas dans le formulaire ne sont ni traités ni signalés par défaut. getExtraData() retourne ces données supplémentaires :
$extra = $form->getExtraData(); // tableau des champs soumis non déclarés dans le formulaire
'allow_extra_fields' => false sur le type racine, une erreur de validation est générée si des champs inconnus sont soumis.
Thématisation — personnaliser le rendu
Le rendu d'un formulaire n'est pas magique : c'est une cascade de blocs Twig, du plus générique au plus spécifique. Comprendre la cascade, c'est surcharger exactement ce qu'il faut.
Quand tu écris {{ form_row(form.email) }}, Twig ne « sait » pas magiquement quoi
produire : il exécute un bloc précis, choisi par une cascade déterministe. Comprendre
cette cascade, c'est pouvoir surcharger exactement le bon morceau — un champ, un type, ou tout le
rendu — sans jamais toucher au reste.
Un template Twig qui définit des blocs de rendu —
form_row, form_widget,
form_label, form_errors, form_help — et leurs variantes par
type de champ (text_widget, checkbox_widget…).
La cascade des block_prefixes
Chaque champ porte, dans ses variables de vue, un tableau block_prefixes construit à
partir de sa hiérarchie de types. Pour un champ email de type
EmailType dans un formulaire registration :
// $form->get('email')->createView()->vars['block_prefixes'] ['form', 'text', 'email', '_registration_email'] // generique -------------------------> specifique
Au moment de rendre le widget, Symfony parcourt ces préfixes du plus spécifique au plus
générique et cherche un bloc nommé {prefixe}_widget. Le premier qui existe
gagne :
C'est toute la « magie » : une recherche de bloc avec repli. Rien n'est caché — tu peux te brancher à n'importe quel niveau de cette chaîne.
form_widget pour tout
changer, text_widget pour tous les champs texte, ou
_registration_email_widget pour ce seul champ. Le même mécanisme, trois portées.
Appliquer un thème
Dans un template, form_theme désigne les templates où chercher les blocs, prioritaires
sur le thème par défaut :
# applique un thème externe à ce formulaire {% form_theme form 'form/mon_theme.html.twig' %} # plusieurs thèmes : le premier a la priorité {% form_theme form 'form/champs.html.twig' 'form/erreurs.html.twig' %} # _self : définir les blocs directement dans le template courant {% form_theme form _self %}
Un thème est un simple template de blocs. Pour envelopper chaque ligne dans une div à classe :
# form/mon_theme.html.twig {% block form_row %} <div class="champ"> {{ form_label(form) }} {{ form_widget(form) }} {{ form_errors(form) }} </div> {% endblock %}
Thèmes globaux et thèmes fournis
Pour appliquer un thème à toute l'application, sans form_theme dans chaque template :
# config/packages/twig.yaml twig: form_themes: ['bootstrap_5_layout.html.twig']
Symfony fournit plusieurs thèmes prêts à l'emploi : form_div_layout.html.twig (le
défaut), bootstrap_5_layout.html.twig, tailwind_2_layout.html.twig et
foundation_6_layout.html.twig, entre autres.
Cibler un champ via block_prefix
Tu peux ajouter un préfixe personnalisé à un champ pour lui donner un bloc dédié réutilisable, indépendant de son nom :
$builder->add('email', EmailType::class, [ 'block_prefix' => 'app_email', // ajoute 'app_email' aux block_prefixes ]); // tu peux maintenant définir un bloc app_email_widget réutilisable
Choisis un type de champ et un nom de formulaire pour voir les block_prefixes générés
et l'ordre exact de recherche du bloc widget :
Formulaire · champ
block_prefixes :
recherche du bloc _widget (premier existant gagne) :
Tests unitaires — vérifier un type en isolation
Un FormType encapsule de la transformation et de la logique. Le tester ne demande ni navigateur ni base : TypeTestCase soumet des données brutes et vérifie le résultat.
Un FormType concentre de la vraie logique : des data transformers, un data mapper, des
events, des options. Le tester ne demande ni navigateur, ni base de données, ni requête
HTTP — tu soumets des données brutes au formulaire et tu vérifies l'objet qui en sort.
Une classe de base (
Symfony\Component\Form\Test\TypeTestCase) qui met en place une
vraie FormFactory en mémoire. Tu y crées ton type, tu le soumets, et tu inspectes le
résultat — le tout en isolation.
Un test de soumission
use Symfony\Component\Form\Test\TypeTestCase; class RegistrationTypeTest extends TypeTestCase { public function testSubmitValidData(): void { $formData = ['name' => 'Alice', 'email' => 'alice@example.com']; $form = $this->factory->create(RegistrationType::class); $form->submit($formData); // isSynchronized() == false ⇒ un reverseTransform() a levé $this->assertTrue($form->isSynchronized()); // getData() == le modèle reconstruit (transformers + data mapper) $expected = new Registration('Alice', 'alice@example.com'); $this->assertEquals($expected, $form->getData()); } }
Deux assertions portent tout le sens du test :
| Assertion | Ce qu'elle vérifie |
|---|---|
isSynchronized() | Aucun reverseTransform() n'a levé — les données de vue sont convertibles vers le modèle |
getData() | Le modèle final, après transformers et data mapper |
Tester un data transformer
C'est le cas le plus rentable : un transformer (section Data transformers) se teste par un aller-retour. On soumet la représentation de vue, on vérifie le modèle obtenu :
// Un transformer "42" (vue) ⇄ Issue #42 (modèle) $form = $this->factory->create(IssueSelectorType::class); $form->submit('42'); $this->assertTrue($form->isSynchronized()); $this->assertSame(42, $form->getData()->getId());
Types avec dépendances : PreloadedExtension
Par défaut, la fabrique instancie tes types sans arguments. Si un type a des dépendances de
constructeur (un repository, un service), enregistre-le toi-même via PreloadedExtension
en surchargeant getExtensions() :
use Symfony\Component\Form\PreloadedExtension; protected function getExtensions(): array { $type = new IssueSelectorType($this->createMock(IssueRepository::class)); return [new PreloadedExtension([$type], [])]; }
TypeTestCase n'active pas l'extension de validation par
défaut. Un test de type se concentre sur la transformation, le mapping
et la soumission — la logique propre au formulaire.
Inspecter la vue
Pour vérifier ce qui est exposé au template (valeurs, enfants, options passées aux vars) :
$view = $this->factory->create(RegistrationType::class)->createView(); $this->assertArrayHasKey('email', $view->children); $this->assertSame('Alice', $view->children['name']->vars['value']);
Synthèse
Le guide de choix des patterns avancés et les anti-patterns du Form Component.
Le Form Component Symfony est un système extensible à plusieurs couches. Maîtriser le bon outil pour chaque besoin évite la sur-ingénierie et les bugs silencieux.
Guide de choix
| Besoin | Outil | Pourquoi |
|---|---|---|
| Format affiché ≠ format stocké | DataTransformer | Traduit sur un champ unique |
| Objet sans getters/setters standard | DataMapper | Contrôle total distribution/collecte |
| Champs conditionnels selon les données | Events (PRE_SET_DATA + PRE_SUBMIT) | Intervention aux deux flux |
| Action après soumission valide | Events (POST_SUBMIT) | Déclenchement post-validation |
| Option/comportement sur tous les X | FormTypeExtension | Augmente sans modifier |
| Liste de sous-formulaires modifiable | CollectionType + prototype JS | Pattern standard Symfony |
| DTO immuable (constructeur) | empty_data en Closure | Simple et suffisant |
Anti-patterns
Un formulaire gère le format des données, pas les règles métier. La validation des règles métier appartient au domaine ou aux handlers, pas aux contraintes de formulaire.
Ajouter un champ conditionnel uniquement dans PRE_SET_DATA est insuffisant — à la soumission, le formulaire est reconstruit et le champ manquera. Il faut toujours le pair PRE_SET_DATA + PRE_SUBMIT.
Sans
by_reference: false, Symfony modifie la collection par référence. Le UnitOfWork Doctrine ne détecte pas les suppressions d'éléments — des données persistent silencieusement.
POST_SUBMIT est en lecture seule. Appeler setData() sur l'événement lance une BadMethodCallException. Pour modifier les données après soumission, utiliser SUBMIT (avec parcimonie).
Récapitulatif des types built-in avancés
| Type | Options clés |
|---|---|
PasswordType | always_empty, trim |
NumberType | scale, grouping, rounding_mode, html5 |
MoneyType | currency, divisor, scale |
DateType | widget (choice/single_text/text), input (datetime/string/timestamp/array) |
ChoiceType | choice_label callable, choice_value, choice_attr, group_by, preferred_choices |
EnumType | class (PHP 8.1 enum), choice_label |
EntityType | class, query_builder, choice_label, choice_value, em |
CollectionType | entry_type, allow_add, allow_delete, prototype, keep_as_list, delete_empty |
RepeatedType | type, first_options, second_options |
FileType | multiple, data_class |