Chapitre 9.2 — Formulaires liés aux entités & aux DTO
Partie 9 : Formulaires — Chapitre 2/5 Version de référence : Symfony 7.4.
⏱️ TL;DR
- L’option
data_classd’unFormTypeindique quel objet le formulaire remplit : une entité (Booking) ou un DTO (CreateBookingInput).- Lier directement à l’entité : simple et rapide (l’entité modifiée est prête à persister). Convient au back-office CRUD.
- Passer par un DTO : plus découplé et sûr (le formulaire ne touche pas l’entité ; un service crée/modifie l’entité après validation). Recommandé dès qu’il y a de la logique, et partagé avec l’API (Partie 11).
- Un même
FormTypesert pour créer et éditer (le contrôleur passe un objet neuf ou existant).- Les collections (
CollectionType) gèrent des sous-formulaires répétés (ex. plusieurs créneaux).- Pour un dev Next.js : le DTO ici = votre schéma d’entrée validé, séparé du modèle de base — exactement la séparation « input type » vs « db model » que vous faites en TS.
🎯 Objectifs
- Choisir entre lier le formulaire à une entité ou à un DTO.
- Réutiliser un
FormTypepour la création et l’édition. - Comprendre pourquoi le DTO découple et sécurise.
- Gérer des sous-formulaires répétés avec
CollectionType.
1. data_class : à quoi le formulaire est lié
Un formulaire remplit un objet. L’option data_class dit lequel :
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults(['data_class' => Booking::class]); // lié à l'entité
// ou
$resolver->setDefaults(['data_class' => CreateBookingInput::class]); // lié à un DTO
}Les noms des champs du formulaire (->add('customerEmail', ...)) doivent correspondre aux propriétés (ou accesseurs) de cet objet.
2. Option A — lier directement à l’entité
Le formulaire remplit une entité ; il n’y a plus qu’à la persister.
#[Route('/admin/providers/{id<\d+>}/edit', methods: ['GET', 'POST'])]
public function edit(Provider $provider, Request $request, EntityManagerInterface $em): Response
{
$form = $this->createForm(ProviderType::class, $provider); // pré-rempli avec l'entité
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$em->flush(); // l'entité est déjà modifiée par le formulaire → juste flush
$this->addFlash('success', 'Prestataire mis à jour.');
return $this->redirectToRoute('admin_provider_index');
}
return $this->render('provider/edit.html.twig', ['form' => $form]);
}Avantages : concis, parfait pour un CRUD admin simple (c’est ce que génère make:crud). Le même ProviderType sert pour créer (passer new Provider()) et éditer (passer l’entité existante).
⚠️ Piège : lier directement à l’entité expose toutes les propriétés listées dans le formulaire à la modification par l’utilisateur. Un champ oublié (
->add('role'),->add('isAdmin')) devient une faille d’élévation de privilèges (mass assignment). Avec une entité, n’exposez que les champs voulus, et méfiez-vous des propriétés sensibles. Le DTO (option B) rend ce risque plus difficile à commettre.
3. Option B — passer par un DTO (recommandé)
Le formulaire remplit un DTO d’entrée (un objet dédié, sans lien avec la base). Un service valide et transforme ce DTO en entité.
#[Route('/admin/bookings/new', methods: ['GET', 'POST'])]
public function new(Request $request, BookingCreator $creator): Response
{
$input = new CreateBookingInput();
$form = $this->createForm(BookingType::class, $input); // data_class = CreateBookingInput
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$booking = $creator->create($input); // le service construit l'entité à partir du DTO validé
return $this->redirectToRoute('admin_booking_index');
}
return $this->render('booking/new.html.twig', ['form' => $form]);
}Pourquoi c’est mieux :
- Découplage : le formulaire ne touche jamais l’entité ; on contrôle exactement ce qui entre.
- Sécurité : pas de mass assignment accidentel sur des champs sensibles de l’entité.
- Réutilisation avec l’API : le même DTO
CreateBookingInput(avec ses#[Assert\...]) est utilisé par#[MapRequestPayload](API JSON, Partie 4.4) et par ce formulaire HTML. Une définition d’entrée, deux portes (formulaire + API). - Logique de création centralisée dans le service (
BookingCreator), testable.
💡 Pour un dev Next.js : c’est la séparation que vous faites déjà en TS entre un type d’entrée (
CreateBookingInput, validé par Zod) et le modèle de base (l’entité Prisma/Doctrine). Le formulaire et l’API partagent le même schéma d’entrée ; le mapping vers la base se fait dans un service. Propre, sûr, DRY.
4. Créer vs éditer avec le même FormType
Un FormType est agnostique de l’opération. Le contrôleur décide :
- Créer : passer un objet neuf (
new Provider()ounew CreateBookingInput()). - Éditer : passer l’objet existant (chargé depuis la base) → le formulaire est pré-rempli.
Si des champs diffèrent entre création et édition (ex. le mot de passe requis à la création, optionnel à l’édition), on passe une option au formulaire ('is_edit' => true) et on adapte buildForm en conséquence.
5. Sous-formulaires répétés : CollectionType
Pour saisir plusieurs sous-objets d’un coup (ex. plusieurs Slot d’un Service), on utilise CollectionType :
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
->add('slots', CollectionType::class, [
'entry_type' => SlotType::class, // le FormType d'un créneau
'allow_add' => true, // permettre d'ajouter des lignes
'allow_delete' => true, // et d'en retirer
'by_reference' => false, // appeler addSlot/removeSlot (sync des 2 côtés, ch. 6.2)
])L’ajout/suppression dynamique de lignes côté navigateur se fait joliment avec un LiveComponent (chapitre 8.3) ou le contrôleur Stimulus form-collection de Symfony UX — pas besoin d’écrire le JS soi-même.
⚠️ Piège
by_reference: avec une relation Doctrine, mettezby_reference => falsepour que Symfony appelle vos helpersaddSlot/removeSlot(qui synchronisent les deux côtés de la relation, chapitre 6.2). Sans ça, le côté propriétaire n’est pas mis à jour et rien n’est persisté — le même piège que les relations.
✏️ Exercices
Exercice 1. Pour un formulaire de création d’utilisateur exposant email, plainPassword et roles, pourquoi préférer un DTO à l’entité User directement ?
✅ Solution
Parce que lier le formulaire à l’entité User risque le mass assignment : un champ roles (ou un champ sensible ajouté par mégarde) laissé modifiable permettrait à l’utilisateur de s’attribuer des rôles (élévation de privilèges). Un DTO RegisterInput (email + plainPassword uniquement, sans roles) cadre exactement ce que l’utilisateur peut fournir ; un service assigne les rôles côté serveur. En prime, ce DTO est réutilisable par l’API (Partie 11).
Exercice 2. Comment le même ProviderType peut-il servir à la fois pour créer et éditer ?
✅ Solution
Le FormType est agnostique de l’opération. Pour créer, le contrôleur passe new Provider() (formulaire vide) ; pour éditer, il passe l’entité existante chargée depuis la base (formulaire pré-rempli). Si des champs diffèrent (ex. mot de passe requis à la création seulement), on passe une option (is_edit) au formulaire et on adapte buildForm.
Exercice 3. Vous ajoutez un CollectionType de créneaux à un formulaire de service, mais les créneaux ajoutés ne sont pas persistés. Quelle option a probablement été oubliée ?
✅ Solution
Probablement by_reference => false. Sans elle, Symfony modifie la collection « par référence » sans appeler addSlot/removeSlot, donc le côté propriétaire de la relation (Slot::$service, porteur de la FK, chapitre 6.2) n’est pas défini → rien n’est persisté. Avec by_reference => false, Symfony appelle les helpers qui synchronisent les deux côtés.
🧠 Quiz de révision
1. À quoi sert data_class ?
data_class ?À indiquer quel objet le formulaire remplit : une entité ou un DTO. Les noms de champs doivent correspondre à ses propriétés/accesseurs.
2. Avantage principal de lier le formulaire directement à l’entité ?
La concision : l’entité modifiée est prête à persister (juste flush()). Idéal pour un CRUD admin simple.
3. Deux avantages de passer par un DTO ?
Au choix : découplage (le formulaire ne touche pas l’entité), sécurité (pas de mass assignment), réutilisation avec l’API (même DTO validé), logique de création centralisée dans un service testable.
4. Le même FormType peut-il créer et éditer ?
Oui : le contrôleur passe un objet neuf (créer) ou existant (éditer, formulaire pré-rempli).
5. Quelle option pour qu’un CollectionType appelle vos helpers addX/removeX ?
addX/removeX ?by_reference => false (indispensable avec les relations Doctrine pour synchroniser les deux côtés).
Prochain chapitre : 9.3 — La validation — contraintes #[Assert\...], groupes, et contraintes personnalisées.