Forms — types, DTOs, events, uploads, theming
TL;DR — Le Form component = 3 couches : (1)
FormTypedéfinit la structure (fields, options), (2) le data binding lie un objet métier ou DTO au form, (3)FormViewest rendu via Twig avec un thème.FormEvents::PRE_SUBMIT(modify raw data),SUBMIT(after binding),POST_SUBMIT(after validation) sont les hooks pour la logique dynamique. Pour les APIs JSON, n'utilise PAS Form → préfère#[MapRequestPayload].
🧠 Mental model — ASCII diagram + analogie
┌─────────────────────────────────────────────────────────────┐
│ Controller │
│ $form = $this->createForm(UserType::class, $userDto); │
│ $form->handleRequest($request); │
│ │
│ if ($form->isSubmitted() && $form->isValid()) { │
│ /** @var UserDto $data */ │
│ $data = $form->getData(); │
│ $service->process($data); │
│ } │
└─────────────────────────────────────────────────────────────┘
Form lifecycle (handleRequest):
Request POST data
│
▼
PRE_SUBMIT ← FormEvent: raw scalar/array data (last chance to mutate)
│
▼
bind to data class via transformers
│
▼
SUBMIT ← FormEvent: bound model data, before validation
│
▼
Validator runs (constraints on data class + form options)
│
▼
POST_SUBMIT ← FormEvent: after validation, errors available
│
▼
$form->isValid() ? true / falseAnalogie : Form = un traducteur diplomatique. HTTP parle "string scalars" (querystring), ton domaine parle "objects typés". Le Form intercepte, traduit (DataTransformer), valide, et te tend ton objet métier prêt à consommer. À chaque étape de la traduction, tu peux intervenir via events.
Les 4 espaces de données — le modèle mental qui débloque tout
90% des bugs Form viennent d'une confusion sur dans quel espace vit une donnée à un instant T. Le Form component gère quatre représentations de la même valeur, reliées par des transformers :
view data norm data model data
(string/array) (intermédiaire) (typé, ton domaine)
│ │ │
│ ViewTransformer │ ModelTransformer │
"31/12/2024" ◄──────────► "2024-12-31" ◄────────► DateTimeImmutable
(input HTML) (canonique) (data_class property)| Espace | Type | Qui le produit | Exemple DateType |
|---|---|---|---|
| view | toujours string/array (HTML ne connaît que ça) | rendu Twig / $_POST | "31/12/2024" |
| norm (normalized) | format canonique pivot | ViewTransformer | "2024-12-31" |
| model | type métier | ModelTransformer | DateTimeImmutable |
Règle staff : un DataTransformer ne valide JAMAIS la logique métier — il ne fait que convertir. S'il ne peut pas convertir (input non parsable), il lève TransformationFailedException → le form devient !isSynchronized() (≠ invalide-par-contrainte). C'est pourquoi $form->isValid() couvre les deux : non-synchronisé et contraintes violées. Distinguer les deux dans les tests (isSynchronized() vs getErrors()) est un réflexe senior.
isValid() décomposé — ce que la méthode agrège réellement
$form->isValid()
// ≡ $form->isSubmitted()
// && $form->isSynchronized() // tous les transformers ont réussi
// && count($form->getErrors(true)) === 0 // aucune contrainte violée (deep=true)Un piège classique : tester isSubmitted() && isValid() après handleRequest() sur une requête GET — isSubmitted() est false, le && court-circuite, OK. Mais si tu appelles $form->submit($data) manuellement, le form est toujours isSubmitted() === true même avec data partielle → tu dois passer $form->submit($data, clearMissing: false) pour un PATCH partiel, sinon les champs absents sont réinitialisés à empty_data.
Form vs alternatives — la décision en une table
| Besoin | Outil | Pourquoi |
|---|---|---|
| Page HTML, CSRF, theming, upload, collections | Form component | Écosystème complet, rendu Twig |
| API JSON, DTO direct, validation | #[MapRequestPayload] | 0 boilerplate, pas de view layer inutile |
| API JSON + query string | #[MapQueryString] | Bind ?page=2&sort=name → DTO typé |
| Form HTML sans binding (filtres, recherche) | Form data_class: null | Récupère un array via getData() |
| Form ultra-dynamique côté client (ajout lignes, dépendances) | Symfony UX LiveComponent | Re-render serveur sans écrire de JS, gère le state |
| Wizard multi-step stateful | Form + Workflow + draft persistance | Voir scénario KYC plus bas |
Heuristique staff : si la réponse n'a pas de HTML (pas de
form_start, pas de CSRF visuel, pas de re-render avec erreurs inline), tu n'as probablement pas besoin du Form component. Le coût caché du Form sur une API JSON, c'est la couche view/transformers que tu paies sans la consommer.
🛠️ Code minimal — Form + DTO + events + collection
// src/Dto/RegistrationDto.php
<?php
namespace App\Dto;
use Symfony\Component\Validator\Constraints as Assert;
final class RegistrationDto
{
#[Assert\NotBlank]
#[Assert\Email]
public ?string $email = null;
#[Assert\NotBlank]
#[Assert\Length(min: 8, max: 72)]
public ?string $plainPassword = null;
#[Assert\IsTrue(message: 'You must accept terms')]
public bool $acceptTerms = false;
/** @var Address[] */
#[Assert\Valid]
#[Assert\Count(min: 1, max: 5)]
public array $addresses = [];
}// src/Form/RegistrationType.php
<?php
namespace App\Form;
use App\Dto\RegistrationDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
final class RegistrationType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('email', EmailType::class, [
'label' => 'form.email',
'attr' => ['autocomplete' => 'email'],
])
->add('plainPassword', RepeatedType::class, [
'type' => PasswordType::class,
'mapped' => true,
'first_options' => ['label' => 'form.password'],
'second_options' => ['label' => 'form.password_repeat'],
'invalid_message' => 'form.password_mismatch',
])
->add('addresses', CollectionType::class, [
'entry_type' => AddressType::class,
'allow_add' => true,
'allow_delete' => true,
'by_reference' => false,
'prototype' => true,
])
->add('acceptTerms', CheckboxType::class, [
'label' => 'form.accept_terms',
])
->add('save', SubmitType::class);
$builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) {
$data = $event->getData();
// Normalize email lowercase before binding
if (is_array($data) && isset($data['email'])) {
$data['email'] = strtolower(trim((string) $data['email']));
$event->setData($data);
}
});
$builder->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event) {
// Last-mile checks (ex: rate limit IP)
});
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => RegistrationDto::class,
'validation_groups' => ['Default', 'registration'],
'csrf_protection' => true,
'csrf_field_name' => '_token',
'csrf_token_id' => 'registration',
]);
}
}// src/Controller/RegistrationController.php
#[Route('/register', name: 'register', methods: ['GET', 'POST'])]
public function register(Request $request): Response
{
$form = $this->createForm(RegistrationType::class, new RegistrationDto());
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$dto = $form->getData();
$this->userService->register($dto);
$this->addFlash('success', 'registration.success');
return $this->redirectToRoute('login');
}
return $this->render('auth/register.html.twig', ['form' => $form]);
}{# templates/auth/register.html.twig — Bootstrap 5 theme #}
{% form_theme form 'bootstrap_5_layout.html.twig' %}
{{ form_start(form, { attr: { novalidate: 'novalidate' } }) }}
{{ form_row(form.email) }}
{{ form_row(form.plainPassword.first) }}
{{ form_row(form.plainPassword.second) }}
<fieldset>
<legend>{{ 'form.addresses'|trans }}</legend>
<ul data-prototype="{{ form_widget(form.addresses.vars.prototype)|e('html_attr') }}">
{% for address in form.addresses %}
<li>{{ form_row(address) }}</li>
{% endfor %}
</ul>
</fieldset>
{{ form_row(form.acceptTerms) }}
{{ form_row(form.save) }}
{{ form_end(form) }}File upload field :
->add('avatar', FileType::class, [
'label' => 'Avatar',
'mapped' => false,
'required' => false,
'constraints' => [
new File(
maxSize: '2M',
mimeTypes: ['image/png', 'image/jpeg'],
mimeTypesMessage: 'Only PNG/JPEG',
),
],
])// In controller
/** @var UploadedFile|null $file */
$file = $form->get('avatar')->getData();
if ($file) {
$name = uniqid('av_', true).'.'.$file->guessExtension();
$file->move($this->getParameter('uploads_dir'), $name);
$dto->avatarPath = $name;
}🔧 DataTransformer & DataMapper — le niveau en dessous
Quand un field a un type métier qui ne correspond pas 1:1 au type HTML, tu écris un transformer. Exemple canonique : un champ texte où l'utilisateur tape un slug d'Issue (PROJ-123) mais le DTO veut l'objet Issue.
// src/Form/DataTransformer/IssueToNumberTransformer.php
<?php
declare(strict_types=1);
namespace App\Form\DataTransformer;
use App\Entity\Issue;
use App\Repository\IssueRepository;
use Symfony\Component\Form\DataTransformerInterface;
use Symfony\Component\Form\Exception\TransformationFailedException;
/** @implements DataTransformerInterface<Issue, string> */
final readonly class IssueToNumberTransformer implements DataTransformerInterface
{
public function __construct(private IssueRepository $issues) {}
// model -> view : ce que Twig affiche dans l'input
public function transform(mixed $value): string
{
return $value instanceof Issue ? $value->getNumber() : '';
}
// view -> model : ce qu'on récupère du POST
public function reverseTransform(mixed $value): ?Issue
{
if (null === $value || '' === $value) {
return null;
}
$issue = $this->issues->findOneByNumber($value);
if (null === $issue) {
// PAS une exception métier : c'est une *désynchronisation*
throw new TransformationFailedException(sprintf('Issue "%s" introuvable.', $value));
}
return $issue;
}
}// Dans un FormType : attache le transformer au field
$builder->add('issue', TextType::class)
->get('issue')
->addModelTransformer($issueTransformer); // injecté via le constructeur du TypeaddModelTransformer vs addViewTransformer : le model transformer agit entre norm↔model (proche du domaine), le view transformer entre view↔norm (proche du HTML). Pour 95% des cas tu veux addModelTransformer. Ordre d'exécution au submit : view → (viewTransformers reverse) → norm → (modelTransformers reverse) → model.
DataMapperInterface — quand tu veux contrôler comment le form lit/écrit l'objet entier (et non field-par-field). Indispensable pour un DTO immutable à constructeur (les properties readonly ne peuvent pas être posées par PropertyAccess) :
// src/Form/DataMapper/MoneyMapper.php — mappe 2 fields (amount, currency) vers un VO Money immutable
<?php
declare(strict_types=1);
namespace App\Form\DataMapper;
use App\Domain\Money;
use Symfony\Component\Form\DataMapperInterface;
use Symfony\Component\Form\FormInterface;
final class MoneyMapper implements DataMapperInterface
{
/** @param \Traversable<string, FormInterface> $forms */
public function mapDataToForms(mixed $viewData, \Traversable $forms): void
{
if (!$viewData instanceof Money) {
return;
}
$forms = iterator_to_array($forms);
$forms['amount']->setData($viewData->amountInCents / 100);
$forms['currency']->setData($viewData->currency);
}
/** @param \Traversable<string, FormInterface> $forms */
public function mapFormsToData(\Traversable $forms, mixed &$viewData): void
{
$forms = iterator_to_array($forms);
// Construit un nouvel objet immutable — pas de mutation in-place
$viewData = new Money(
amountInCents: (int) round(((float) $forms['amount']->getData()) * 100),
currency: (string) $forms['currency']->getData(),
);
}
}// Branchement dans le FormType parent
$builder->setDataMapper(new MoneyMapper());Réflexe staff :
empty_data(closure) gère l'instanciation initiale d'un objet immutable,DataMapperInterfacegère la lecture/écriture des sous-champs. Les deux ensemble = support complet des VO/DTOreadonlysans setters.
🎯 Patterns courants
- DTO > Entity binding — toujours bind à un DTO (validation isolée, pas de side effect ORM, plus de contrôle). Mapper DTO → Entity dans un service.
mapped: false— pour un field qui ne correspond pas à une propriété du data class (ex: confirm-password, captcha, upload temp). Récupéré via$form->get('foo')->getData().- CollectionType + JS prototype — pour formulaires dynamiques (ajout/suppression de sub-forms).
data-prototyperendu côté Twig, JS clone/remove. PRE_SET_DATAevent — modifier la structure du form en fonction des données initiales (ex: champs supplémentaires si user est admin). Différent de PRE_SUBMIT (runtime data).- Form theming par défaut —
framework.form.themes: ['bootstrap_5_layout.html.twig']ou theme custom (en Symfony 7,legacy_error_messagesa été supprimé — le nouveau comportement est le seul).form_themeTwig pour scope local. empty_data— initialiser un DTO immutable (constructor non-default). Soit closureempty_data: fn(FormInterface $f) => new MyDto(...), soit class string.
🔄 Versions — Symfony 5.4 / 6.4 / 7.x
- 5.4 : Form component mature.
EnumTypearrive officiellement (5.4), bindable à un BackedEnum PHP 8.1. - 6.0 : suppression de plusieurs deprecated form types (legacy date types, RadioType natif).
inherit_data: truetoujours OK pour fieldsets virtuels. - 6.1 : amélioration de l'auto-extension (
ExtensionInterfaceauto-tagged),form_themesoverrides nested mieux. - 6.2 :
PasswordTypeajoutehash_property_pathpour hash auto viaPasswordHasher. - 6.3 : meilleur support
MoneyTypeavec Intl,NumberTypepeut être strict (html5: trueretire les,/.confusions). - 6.4 LTS : DivisorType pour scaling, amélioration validation messages localization. Form component reste un choix légitime pour les UI HTML traditionnelles ; pour API JSON pure,
#[MapRequestPayload]préféré. - 7.0 : suppression des transformers legacy. Removal de
framework.form.legacy_error_messages(true par défaut, maintenant le default est false). - 7.1+ : amélioration
FormFactoryInterface::createNamed()pour des forms nested génériques.
⚠️ Pitfalls
by_reference: true(default) + immutable DTO — Symfony Form essaie de modifier l'objet via setters/reflection. Avecreadonly, ça throw. Solution :by_reference: false+ DTO mutable, ou setters.- CSRF token expired — par défaut, le token est lié à la session ID. Session expiry = form invalid. Ajuster
framework.csrf_protection.session_tokens_max: 100ou strategy. handleRequest()+ JSON body —handleRequestlit$_POST(form-urlencoded). Pour JSON, il faut populer manuellement ($form->submit($jsonData)).- CollectionType
allow_deletemais pasby_reference: false— la collection est dé-attachée mais pas re-attachée → modifications ignorées. Toujours mettreby_reference: falseavec collections mutables. empty_datamal configuré — DTO avec constructor args required → form throws au boot quand il essaie d'instancier sans args. Toujours fournirempty_datacallable.- Validation groups confusion —
validation_groups: ['Default', 'registration']sur le form parent, mais sub-form a son propreDefault→ règles dupliquées ou manquantes. Préférer des groupes explicites partout. error_bubbling— par défaut false sur les fields scalars. Erreur sur sub-field n'apparaît pas au niveau parent. À configurer si tu fais un global error display.label_attr+label— passerlabel: falsedésactive le label maislabel_attrest toujours appliqué (parfois visible via CSS). Le supprimer aussi si label off.
🏭 Production — sécurité, perf, observabilité
Sécurité (le Form est une surface d'attaque)
| Menace | Défense Form | Note |
|---|---|---|
| CSRF | csrf_protection: true (default) + csrf_token_id unique par form | Stateless API ? désactive et signe autrement (JWT/SameSite) |
| Mass assignment | Bind un DTO, jamais l'Entity directement | Un attaquant ne peut poster que les fields déclarés dans le Type |
Champs injectés (isAdmin=1) | Seuls les fields add()és sont mappés ; extra fields → allow_extra_fields: false (default) lève une erreur | Vérifie que tu n'as pas mis allow_extra_fields: true "pour debug" |
| Upload malveillant | Assert\File(mimeTypes: ...) valide le vrai MIME (via fileinfo), pas l'extension cliente | + ne sers jamais l'upload depuis un dir exécutable ; renomme (uniqid), stocke hors web-root ou sur S3 |
| Path traversal sur filename | Ne JAMAIS utiliser $file->getClientOriginalName() comme nom de stockage | Toujours un nom généré côté serveur |
| Open redirect après submit | redirectToRoute() avec route nommée, jamais redirect($request->get('next')) brut | Whiteliste les targets |
| Timing/enumeration (login) | Même message d'erreur, même temps, quel que soit le champ fautif | Le Form ne le fait pas pour toi |
Le DTO-binding est la défense mass-assignment la plus importante. Avec une Entity bindée +
by_reference: true, un champ oublié dans le Type mais présent dans le POST n'est pas mappé — mais le jour où quelqu'un ajoute->add('role')pour un usage admin sans validation_groups, tu as une élévation de privilège. Un DTO sépare ce que le form accepte de ce que l'entité contient.
Performance
- Coût du build : un
FormTypecomplexe (collections, country/timezone/currency types qui chargent l'Intl ICU) coûte du CPU à chaque requête.ChoiceTypeavec 40k choices (ex: toutes les communes) → matérialise 40kChoiceView. Pour ces cas, autocomplete AJAX (Symfony UX Autocomplete /EntityTypeavecquery_builder+choice_loaderlazy) au lieu de charger tout. EntityTypeN+1 : chaque option déclenche potentiellement un load. Utilisequery_builderpour précharger, etchoice_labelsur une propriété déjà hydratée.- CollectionType +
prototype: le prototype est rendu une fois (template), le clonage est côté JS → coût serveur constant quel que soit le nombre de lignes. C'est le bon pattern. - Validation : les groupes évitent de tout re-valider à chaque step. Sur un wizard, valider
['step3']seulement, pas['Default']complet.
Observabilité
- Le profiler (
/_profiler, panneau Form) montre l'arbre complet : valeurs view/norm/model, transformers appliqués, erreurs par field, options résolues. C'est ton premier réflexe de debug, avant toutdump(). - En prod, logge les échecs de synchronisation (
!isSynchronized()) séparément des erreurs de validation : un pic de désynchro = soit une attaque (payload malformé), soit un bug de transformer après un déploiement. - Audit-log des
FormEventssur les forms sensibles (KYC, paiement, RGPD) : qui a soumis quoi, quand, depuis quelle IP. Branche un listener global surPOST_SUBMIT.
🧪 Testing
// tests/Form/RegistrationTypeTest.php
<?php
namespace App\Tests\Form;
use App\Dto\RegistrationDto;
use App\Form\RegistrationType;
use Symfony\Component\Form\Test\TypeTestCase;
use Symfony\Component\Form\Extension\Validator\ValidatorExtension;
use Symfony\Component\Validator\Validation;
final class RegistrationTypeTest extends TypeTestCase
{
protected function getExtensions(): array
{
$validator = Validation::createValidatorBuilder()
->enableAttributeMapping()
->getValidator();
return [new ValidatorExtension($validator)];
}
public function testSubmitValidData(): void
{
$formData = [
'email' => '[email protected]',
'plainPassword' => ['first' => 'secret123', 'second' => 'secret123'],
'acceptTerms' => true,
'addresses' => [],
];
$form = $this->factory->create(RegistrationType::class);
$form->submit($formData);
self::assertTrue($form->isSynchronized());
// Note: validation needs at least 1 address — this would fail validation
}
public function testPreSubmitNormalizesEmail(): void
{
$form = $this->factory->create(RegistrationType::class);
$form->submit([
'email' => ' [email protected] ',
'plainPassword' => ['first' => 'secret123', 'second' => 'secret123'],
'acceptTerms' => true,
'addresses' => [['street' => '1 rue X', 'city' => 'Paris']],
]);
/** @var RegistrationDto $dto */
$dto = $form->getData();
self::assertSame('[email protected]', $dto->email);
}
}Functional test :
public function testRegistrationFlow(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/register');
self::assertResponseIsSuccessful();
$form = $crawler->selectButton('Save')->form([
'registration[email]' => '[email protected]',
'registration[plainPassword][first]' => 'secret123',
'registration[plainPassword][second]' => 'secret123',
'registration[acceptTerms]' => '1',
]);
$client->submit($form);
self::assertResponseRedirects('/login');
}🎬 Cas d'usage concrets
Scénario 1 — Cabinet juridique : formulaire d'intake client avec pièces justificatives
Contexte : cabinet d'avocats en droit des affaires accepte de nouveaux clients via un portail web. Le formulaire d'intake demande : (1) identité (particulier ou entreprise), (2) nature du litige (énumération de 20+ cas), (3) timeline du litige (collection de dates), (4) upload de 1 à 10 pièces (PDF, scans), (5) signature électronique d'un accord d'honoraires. La saisie peut prendre 25 min, l'utilisateur doit pouvoir sauvegarder et revenir.
Le ClientIntakeType Symfony Form est mappé sur un ClientIntakeDto. Les champs conditionnels (form events PRE_SET_DATA et PRE_SUBMIT) affichent le sous-formulaire entreprise si "particulier" est décoché. La CollectionType pieces permet d'ajouter dynamiquement des FileType (avec allow_add, JS minimal) — validation Assert\File(maxSize: '20M', mimeTypes: ['application/pdf', 'image/jpeg', 'image/png']). Un FormEvents::PRE_SUBMIT injecte la date du jour si absente, gère les valeurs vides → null pour les champs optionnels.
Pour la persistance partielle, à chaque sub-submit le DTO est sérialisé en JSON et stocké en session ; à la reprise, hydration depuis session. Soumission finale → entité Client + IntakeDossier, fichiers uploadés sur S3 via VichUploaderBundle.
Scénario 2 — FinTech KYC : formulaire multi-step Stripe-like
Contexte : néobanque pro qui doit collecter le KYC légal de chaque nouvelle entreprise cliente. Process en 5 étapes : (1) identité représentant légal, (2) identité entreprise (SIRET, SIREN, code APE), (3) bénéficiaires effectifs (collection), (4) upload Kbis + pièce d'identité, (5) validation et signature.
Chaque étape = un FormType distinct (KycStep1Type, KycStep2Type, etc.), mappé sur le même DTO KycApplicationDto partiellement rempli, persisté en base entre étapes. Validation par groupes : groups=['step1'], ['step2']. Une transition WorkflowInterface (Symfony Workflow) verrouille la progression : impossible de passer à step4 sans avoir validé step3. Le form de l'étape 4 utilise DataMapperInterface custom pour gérer l'upload simultané de plusieurs fichiers vers un service KycDocumentStore.
Conformité : tous les events forms sont audit-loggés (qui a modifié quoi quand), les fichiers sont chiffrés au repos avec clé KMS par tenant.
Scénario 3 — RH (Welcome to the Jungle-like) : ATS — formulaire de candidature
Contexte : ATS (Applicant Tracking System) qui reçoit ~30 000 candidatures/mois. Formulaire public : CV (PDF), lettre de motivation (texte), réponses à 5 questions custom par offre (chaque entreprise définit ses questions), info perso, consentement RGPD explicite.
Le JobApplicationType lit la config des questions custom depuis l'entité JobOffer (passée en option) et génère dynamiquement les fields via FormBuilderInterface::add() en boucle. Pour le CV, un FileType avec validation mimeTypes + un service CvParser (extraction OCR async via Messenger) qui pré-remplit les champs profil. Le consentement RGPD = CheckboxType requis (Assert\IsTrue) avec lien vers politique de confidentialité.
Anti-spam : un honeypot field (hidden que les bots remplissent), un Turnstile Cloudflare, et un rate limiter Symfony à 5 candidatures/h/IP. Une fois soumise, l'application déclenche un workflow recruteur : un ApplicationReceived event part en Messenger → notify Slack RH + email candidat de confirmation.
🛠️ Exemple end-to-end
Use case : formulaire KYC étape "bénéficiaires effectifs" avec collection de personnes, upload de pièces, validation groupée, persistance partielle.
// src/Kyc/Dto/KycApplicationDto.php
<?php
declare(strict_types=1);
namespace App\Kyc\Dto;
use Symfony\Component\Validator\Constraints as Assert;
final class KycApplicationDto
{
#[Assert\NotBlank(groups: ['step1'])]
public ?string $companyLegalName = null;
#[Assert\NotBlank(groups: ['step2'])]
#[Assert\Length(exactly: 14, groups: ['step2'])]
public ?string $siret = null;
/** @var list<UltimateBeneficialOwnerDto> */
#[Assert\Valid(groups: ['step3'])]
#[Assert\Count(min: 1, max: 10, groups: ['step3'])]
public array $ubos = [];
/** @var array<string, ?\Symfony\Component\HttpFoundation\File\UploadedFile> */
public array $documents = [];
}
final class UltimateBeneficialOwnerDto
{
#[Assert\NotBlank(groups: ['step3'])]
public ?string $firstName = null;
#[Assert\NotBlank(groups: ['step3'])]
public ?string $lastName = null;
#[Assert\NotNull(groups: ['step3'])]
public ?\DateTimeImmutable $birthDate = null;
#[Assert\NotBlank(groups: ['step3'])]
#[Assert\Range(min: 25, max: 100, groups: ['step3'])]
public ?int $ownershipPercentage = null;
#[Assert\Country(groups: ['step3'])]
public ?string $nationality = null;
}// src/Kyc/Form/UboType.php
<?php
declare(strict_types=1);
namespace App\Kyc\Form;
use App\Kyc\Dto\UltimateBeneficialOwnerDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CountryType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
final class UboType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('firstName', TextType::class, ['label' => 'Prénom'])
->add('lastName', TextType::class, ['label' => 'Nom'])
->add('birthDate', DateType::class, [
'widget' => 'single_text',
'input' => 'datetime_immutable',
'label' => 'Date de naissance',
])
->add('ownershipPercentage', IntegerType::class, [
'label' => '% de détention',
'attr' => ['min' => 25, 'max' => 100],
])
->add('nationality', CountryType::class, ['label' => 'Nationalité']);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => UltimateBeneficialOwnerDto::class,
]);
}
}// src/Kyc/Form/KycStep3Type.php
<?php
declare(strict_types=1);
namespace App\Kyc\Form;
use App\Kyc\Dto\KycApplicationDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
final class KycStep3Type extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('ubos', CollectionType::class, [
'entry_type' => UboType::class,
'allow_add' => true,
'allow_delete' => true,
'by_reference' => false,
'prototype' => true,
'label' => 'Bénéficiaires effectifs (≥ 25%)',
])
->add('save', SubmitType::class, ['label' => 'Continuer'])
;
$builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event): void {
$data = $event->getData();
$totalShare = array_sum(array_column($data['ubos'] ?? [], 'ownershipPercentage'));
if ($totalShare > 100) {
$event->getForm()->addError(new \Symfony\Component\Form\FormError(
'La somme des parts dépasse 100%',
));
}
});
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => KycApplicationDto::class,
'validation_groups' => ['step3'],
]);
}
}// src/Controller/KycStep3Controller.php
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Kyc\Form\KycStep3Type;
use App\Kyc\Persistence\KycDraftRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Workflow\WorkflowInterface;
#[IsGranted('ROLE_KYC_APPLICANT')]
final class KycStep3Controller extends AbstractController
{
public function __construct(
private readonly KycDraftRepository $drafts,
private readonly WorkflowInterface $kycWorkflow,
) {}
#[Route('/kyc/step3', name: 'kyc_step3', methods: ['GET', 'POST'])]
public function __invoke(Request $request): Response
{
$draft = $this->drafts->findOrCreateForCurrentUser();
if (!$this->kycWorkflow->can($draft, 'go_step3')) {
return $this->redirectToRoute('kyc_step2');
}
$form = $this->createForm(KycStep3Type::class, $draft->getDto());
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->drafts->save($draft);
$this->kycWorkflow->apply($draft, 'go_step4');
return $this->redirectToRoute('kyc_step4');
}
return $this->render('kyc/step3.html.twig', ['form' => $form]);
}
}L'utilisateur saisit ses UBOs, peut ajouter/supprimer des lignes (JS minimal via Symfony UX LiveComponent), la validation step3 ne bloque que ce qui concerne cette étape, et le passage à step4 transite seulement si workflow l'autorise. Reprise possible 7 jours après abandon via lien email.
🔁 Quand utiliser / éviter
- Form component : pages HTML, formulaires de signup/edit profile, admin CRUD. Plein écosystème (CSRF, theming, file upload, collections).
#[MapRequestPayload](pas Form) : APIs JSON. Plus simple, moins de code, intégration DTO directe.- CollectionType : sub-forms répétés (addresses, items). Évite pour > 50 items (UX lourde, préfère un sub-page CRUD).
mapped: false: champs metadata (captcha, conditions). Évite si tu peux mettre dans le DTO (plus propre).- FormEvents : logique conditionnelle (afficher un field selon une valeur). Évite pour validation pure → utilise Validator constraints + groups.
🏋️ Exercices
Progression : implémenter → production-grade → casser puis réparer. Crée un petit projet Symfony 7.x (symfony new --webapp) et fais tourner php bin/phpunit à chaque étape.
1. Form de filtre sans data_class (échauffement)
Objectif : construire un ProductFilterType non mappé (data_class: null, csrf_protection: false, method: GET) qui produit un array de filtres exploité par un repository. Indice/Solution : getData() rend l'array brut ; bind à un RequestStack/query string via handleRequest. Pas de DTO. Vérifie que ?category=foo&min_price=10 se mappe bien et que les champs vides ne polluent pas la query (PRE_SUBMIT qui unset les '').
2. Champ custom via DataTransformer
Objectif : un TagsType où l'utilisateur tape php, symfony, forms (texte) et le DTO reçoit un list<Tag> (entités, créées si absentes). Indice/Solution : addModelTransformer ; transform joint les tags par , ; reverseTransform split + findOrCreate par nom. Lève TransformationFailedException si un tag dépasse 50 car. Teste isSynchronized() sur input invalide.
3. DTO immutable + empty_data + DataMapper (production-grade)
Objectif : binder un final readonly class CheckoutDto (constructeur, propriétés readonly) sans aucun setter. Le form a amount + currency mappés vers un VO Money. Indice/Solution : empty_data: fn(FormInterface $f) => new CheckoutDto(...) pour l'instanciation, setDataMapper(new MoneyMapper()) (cf. section DataMapper) pour la lecture/écriture des sous-champs. Pas de by_reference. Test : soumets, vérifie que getData() est une nouvelle instance immutable.
4. Wizard multi-step stateful avec validation par groupes
Objectif : 3 étapes mappées sur un seul DTO persisté en draft (DB ou session), validation ['step1']/['step2']/['step3'], impossible de sauter une étape. Indice/Solution : un WorkflowInterface (Symfony Workflow) garde la progression (can($draft, 'go_step2')). Chaque step submit clearMissing: false pour ne pas écraser les champs des autres steps. Reprise par lien email = retrouver le draft par token.
5. CollectionType dynamique + invariant cross-field (production-grade)
Objectif : un BudgetType avec une CollectionType de lignes (label, amount), allow_add/allow_delete, et un invariant : la somme des amount doit égaler un total saisi ailleurs dans le form. Indice/Solution : by_reference: false obligatoire. Invariant cross-field → Assert\Callback sur le DTO ou un POST_SUBMIT qui addError sur le form racine. Côté JS : data-prototype + clonage. Bonus : remplace le JS par un Symfony UX LiveComponent.
6. Casser puis réparer — la collection fantôme
Objectif : reproduire le bug "les suppressions de la collection sont ignorées", puis le corriger et écrire le test de non-régression. Indice/Solution : pars d'une collection avec allow_delete: true mais sans by_reference: false et bindée à une Entity Doctrine avec addX()/removeX(). Les removeX() ne sont jamais appelés → orphelins en DB. Fix : by_reference: false (+ orphanRemoval: true côté mapping si tu veux la suppression physique). Test : crée 2 enfants, supprime-en 1 via submit, assert que la collection model a bien 1 élément et que removeX() a été appelé (spy/mock).
🎤 En entretien
Q : Pourquoi binder un DTO plutôt que l'Entity Doctrine directement dans un Form ? R : Isolation de la validation (groupes propres au use-case sans polluer l'entité), pas de side-effect ORM si la validation échoue (une Entity bindée + mappée reste modifiée en mémoire même si isValid() est faux → risque de flush accidentel), et surtout défense mass-assignment : le form n'accepte que ce que le Type déclare, séparé de ce que l'entité contient.
Q : Différence entre PRE_SUBMIT, SUBMIT et POST_SUBMIT ? R : PRE_SUBMIT = data brute (string/array du POST), dernière chance de la muter/ajouter des champs dynamiques avant binding. SUBMIT = data déjà transformée en model, avant validation, pour ajuster l'objet. POST_SUBMIT = après validation, erreurs disponibles, read-only sur la data — c'est là qu'on fait l'audit-log ou un invariant cross-field. Ordre du parent vers les enfants pour PRE_SUBMIT, des enfants vers le parent pour POST_SUBMIT.
Q : $form->isSynchronized() est false — que s'est-il passé, et est-ce la même chose qu'une erreur de validation ? R : Non, ce sont deux échecs distincts qu'isValid() agrège. !isSynchronized() signifie qu'un DataTransformer a levé TransformationFailedException (l'input ne se convertit pas dans le type cible — ex: "abc" dans un IntegerType, ou un slug inexistant dans un transformer custom). Une erreur de validation, elle, vient du Validator sur une data correctement transformée. Conséquence : un transformer ne doit porter que de la conversion, jamais une règle métier (ça, c'est une contrainte).
Q : Comment gérer un form sur une API où le client envoie du JSON ? R : Le Form component n'est pas le bon outil — handleRequest() lit le body form-urlencoded, pas le JSON. Soit on decode le JSON et on appelle $form->submit($data, clearMissing: false) manuellement (pour supporter le PATCH partiel), soit — bien mieux en Symfony 6.3+ — on abandonne le Form au profit de #[MapRequestPayload] qui désérialise + valide directement dans un DTO typé, sans la couche view/transformers qu'on ne consomme pas.