/form-type — Conception d'un FormType Symfony
Tu conçois ou complètes une classe FormType (src/Form/…Type.php). Tu t'appuies sur make:form, tu choisis les bons types de champ, tu passes par configureOptions pour toute variante, et tu n'écris jamais un form inline dans un contrôleur.
Détection préalable (obligatoire)
- Lire
composer.json.
- Vérifier
symfony/framework-bundle et symfony/form.
- Présents → OK.
symfony/form absent → composer require symfony/form avant de continuer.
- Stack non-Symfony → demander : « Ce skill cible Symfony, je ne trouve pas les paquets attendus. On continue quand même ? »
- Si
sylius/sylius présent → les forms Sylius héritent de AbstractResourceType et utilisent les groupes de validation Default + sylius.
Règles fondamentales
make:form d'abord : symfony console make:form <Nom>Type <Entité> génère le squelette mappé sur l'entité. Pas d'écriture manuelle complète.
- Un FormType = un cas d'usage métier.
ProductCreateType et ProductEditType peuvent diverger (champs différents, validation différente) — ne pas forcer la réutilisation avec des options mode=creation|edit qui rendent le type illisible. Si 80 % des champs sont communs, extraire un parent via getParent().
data_class toujours sur un form mappé à une entité. Sans ça, Symfony ne peut pas deviner les types ni appliquer la validation de l'entité.
- Types explicites :
TextType::class, ChoiceType::class, EntityType::class… Ne jamais passer null ou omettre le type sauf quand le type guessing est intentionnel (champ dérivé du mapping Doctrine sans option custom).
property_path quand le nom du champ dans le form ne matche pas la propriété PHP (ex: champ email exposé mais propriété username côté entité).
mapped: false pour les champs hors entité (captcha, confirmation de mot de passe en clair, acceptation des CGU). Ces champs sont lus via $form->get('…')->getData(), pas depuis l'objet.
- Options custom via
configureOptions + OptionsResolver : chaque variante (require_due_date, current_user, etc.) est déclarée, typée, avec une valeur par défaut. Jamais de globale, jamais $GLOBALS.
- Services injectables via le constructeur (autowiring). Ne pas appeler le container dans
buildForm.
Types de champ les plus fréquents
| Besoin |
Type |
Options clés |
| Texte court |
TextType |
empty_data, trim |
| Texte long |
TextareaType |
attr: {rows: 6} |
| Email |
EmailType |
validation #[Assert\Email] côté entité |
| Nombre entier / décimal |
IntegerType / NumberType |
scale, html5: true |
| Montant |
MoneyType |
currency, divisor: 100 (stockage cents) |
| Choix statique |
ChoiceType |
choices, expanded, multiple |
| Choix depuis une entité |
EntityType |
class, choice_label, query_builder |
| Date / datetime |
DateType / DateTimeType |
widget: 'single_text', input: 'datetime_immutable' |
| Booléen visible |
CheckboxType |
required: false quasi systématique |
| Fichier |
FileType |
cf. /symfony:form-advanced |
| Sous-formulaire |
<Autre>Type::class |
réutilisation d'un FormType |
| Collection de sous-formulaires |
CollectionType |
cf. /symfony:form-advanced |
| Soumission |
SubmitType |
à éviter dans le type — mettre dans le template |
SubmitType dans buildForm couple le form au template. Préférer {{ form_widget(form) }} puis un <button> séparé dans le template, sauf si le form a plusieurs boutons qui partagent la logique (ex: save + saveAndAdd — alors les mettre dans le type).
Déroulement
1 — Cadrer
- Entité cible (ou form sans
data_class).
- Cas d'usage : création, édition, filtre de recherche, action ponctuelle (reset password) ?
- Champs exposés, champs masqués, champs non mappés.
- Options variables entre contextes (
require_*, utilisateur courant, channel Sylius).
2 — Générer
symfony console make:form <Nom>Type <Entité> # entité optionnelle
3 — Compléter le générateur
Pour chaque champ :
- Type explicite (cf. tableau ci-dessus).
- Options :
label, help, required, empty_data, placeholder, attr: {…}.
choices ou query_builder pour les ChoiceType / EntityType. Pour EntityType, toujours un query_builder si la liste peut dépasser quelques dizaines de lignes — sinon toutes les lignes sont chargées.
- Contraintes supplémentaires via l'option
constraints: [...] uniquement si elles sont propres au form (cf. /symfony:doctrine-entity pour la validation de l'entité).
4 — configureOptions
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Product::class,
'require_sku' => false,
]);
$resolver->setAllowedTypes('require_sku', 'bool');
}
Les options custom sont lues dans buildForm via $options['require_sku'].
5 — Services injectés
public function __construct(private readonly Security $security) {}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$user = $this->security->getUser();
// ... utiliser $user pour filtrer un query_builder, etc.
}
6 — Vérification
vendor/bin/phpstan analyse src/Form
Tester via /symfony:form-handle (controller) ou un FormTestCase dédié.
7 — Clôture
Afficher :
- FormType créé/modifié (chemin).
- Champs, types, options custom.
- Ce qui reste : contrôleur (→
/symfony:form-handle), template Twig (→ /symfony:form-render), transformer/events/collection si besoin (→ /symfony:form-advanced).
Delta Sylius
- Form sur une resource Sylius surchargée → hériter de
AbstractResourceType quand le vendor le fait, pas de AbstractType.
- Groupes de validation :
validation_groups: ['Default', 'sylius'] dans configureOptions pour que les contraintes Sylius passent.
- Data channel-scopée → injecter
ChannelContextInterface et filtrer les EntityType::query_builder par channel. Sinon fuite entre boutiques.
Argument optionnel
/symfony:form-type ProductType Product — génère ProductType mappé sur Product.
/symfony:form-type src/Form/OrderType.php — audit d'un type existant (types explicites, options, data_class).
/symfony:form-type sans argument — demande l'entité et le cas d'usage.
1---2name: form-type3description: Conçoit une classe FormType Symfony — AbstractType, buildForm, configureOptions, types de champs (ChoiceType, EntityType…). Déclenche sur "créer FormType", "buildForm", "data_class", "EntityType". Impose make:form.4---56# /form-type — Conception d'un FormType Symfony78Tu conçois ou complètes une classe FormType (`src/Form/…Type.php`). Tu t'appuies sur `make:form`, tu choisis les bons types de champ, tu passes par `configureOptions` pour toute variante, et tu n'écris **jamais** un form inline dans un contrôleur.910## Détection préalable (obligatoire)11121. Lire `composer.json`.132. Vérifier `symfony/framework-bundle` **et** `symfony/form`.14 - Présents → OK.15 - `symfony/form` absent → `composer require symfony/form` avant de continuer.16 - Stack non-Symfony → demander : *« Ce skill cible Symfony, je ne trouve pas les paquets attendus. On continue quand même ? »*173. Si `sylius/sylius` présent → les forms Sylius héritent de `AbstractResourceType` et utilisent les groupes de validation `Default` + `sylius`.1819## Règles fondamentales2021- **`make:form` d'abord** : `symfony console make:form <Nom>Type <Entité>` génère le squelette mappé sur l'entité. Pas d'écriture manuelle complète.22- **Un FormType = un cas d'usage métier**. `ProductCreateType` et `ProductEditType` peuvent diverger (champs différents, validation différente) — ne pas forcer la réutilisation avec des options `mode=creation|edit` qui rendent le type illisible. Si 80 % des champs sont communs, extraire un parent via `getParent()`.23- **`data_class` toujours** sur un form mappé à une entité. Sans ça, Symfony ne peut pas deviner les types ni appliquer la validation de l'entité.24- **Types explicites** : `TextType::class`, `ChoiceType::class`, `EntityType::class`… Ne jamais passer `null` ou omettre le type **sauf** quand le type guessing est intentionnel (champ dérivé du mapping Doctrine sans option custom).25- **`property_path`** quand le nom du champ dans le form ne matche pas la propriété PHP (ex: champ `email` exposé mais propriété `username` côté entité).26- **`mapped: false`** pour les champs hors entité (captcha, confirmation de mot de passe en clair, acceptation des CGU). Ces champs sont lus via `$form->get('…')->getData()`, pas depuis l'objet.27- **Options custom** via `configureOptions` + `OptionsResolver` : chaque variante (`require_due_date`, `current_user`, etc.) est déclarée, typée, avec une valeur par défaut. Jamais de globale, jamais `$GLOBALS`.28- **Services injectables** via le constructeur (autowiring). Ne pas appeler le container dans `buildForm`.2930## Types de champ les plus fréquents3132| Besoin | Type | Options clés |33|-------------------------------------|----------------------------|---------------------------------------------|34| Texte court | `TextType` | `empty_data`, `trim` |35| Texte long | `TextareaType` | `attr: {rows: 6}` |36| Email | `EmailType` | validation `#[Assert\Email]` côté entité |37| Nombre entier / décimal | `IntegerType` / `NumberType` | `scale`, `html5: true` |38| Montant | `MoneyType` | `currency`, `divisor: 100` (stockage cents) |39| Choix statique | `ChoiceType` | `choices`, `expanded`, `multiple` |40| Choix depuis une entité | `EntityType` | `class`, `choice_label`, `query_builder` |41| Date / datetime | `DateType` / `DateTimeType`| `widget: 'single_text'`, `input: 'datetime_immutable'` |42| Booléen visible | `CheckboxType` | `required: false` quasi systématique |43| Fichier | `FileType` | cf. `/symfony:form-advanced` |44| Sous-formulaire | `<Autre>Type::class` | réutilisation d'un FormType |45| Collection de sous-formulaires | `CollectionType` | cf. `/symfony:form-advanced` |46| Soumission | `SubmitType` | à éviter dans le type — mettre dans le template |4748`SubmitType` dans `buildForm` couple le form au template. Préférer `{{ form_widget(form) }}` puis un `<button>` séparé dans le template, sauf si le form a plusieurs boutons qui partagent la logique (ex: `save` + `saveAndAdd` — alors les mettre dans le type).4950## Déroulement5152### 1 — Cadrer5354- Entité cible (ou form sans `data_class`).55- Cas d'usage : création, édition, filtre de recherche, action ponctuelle (reset password) ?56- Champs exposés, champs masqués, champs non mappés.57- Options variables entre contextes (`require_*`, utilisateur courant, channel Sylius).5859### 2 — Générer6061```bash62symfony console make:form <Nom>Type <Entité> # entité optionnelle63```6465### 3 — Compléter le générateur6667Pour chaque champ :6869- Type explicite (cf. tableau ci-dessus).70- Options : `label`, `help`, `required`, `empty_data`, `placeholder`, `attr: {…}`.71- `choices` ou `query_builder` pour les `ChoiceType` / `EntityType`. Pour `EntityType`, **toujours** un `query_builder` si la liste peut dépasser quelques dizaines de lignes — sinon toutes les lignes sont chargées.72- Contraintes **supplémentaires** via l'option `constraints: [...]` uniquement si elles sont propres au form (cf. `/symfony:doctrine-entity` pour la validation de l'entité).7374### 4 — `configureOptions`7576```php77public function configureOptions(OptionsResolver $resolver): void78{79 $resolver->setDefaults([80 'data_class' => Product::class,81 'require_sku' => false,82 ]);83 $resolver->setAllowedTypes('require_sku', 'bool');84}85```8687Les options custom sont lues dans `buildForm` via `$options['require_sku']`.8889### 5 — Services injectés9091```php92public function __construct(private readonly Security $security) {}9394public function buildForm(FormBuilderInterface $builder, array $options): void95{96 $user = $this->security->getUser();97 // ... utiliser $user pour filtrer un query_builder, etc.98}99```100101### 6 — Vérification102103```bash104vendor/bin/phpstan analyse src/Form105```106107Tester via `/symfony:form-handle` (controller) ou un `FormTestCase` dédié.108109### 7 — Clôture110111Afficher :112113- FormType créé/modifié (chemin).114- Champs, types, options custom.115- Ce qui reste : contrôleur (→ `/symfony:form-handle`), template Twig (→ `/symfony:form-render`), transformer/events/collection si besoin (→ `/symfony:form-advanced`).116117## Delta Sylius118119- Form sur une resource Sylius surchargée → hériter de `AbstractResourceType` quand le vendor le fait, pas de `AbstractType`.120- Groupes de validation : `validation_groups: ['Default', 'sylius']` dans `configureOptions` pour que les contraintes Sylius passent.121- Data channel-scopée → injecter `ChannelContextInterface` et filtrer les `EntityType::query_builder` par channel. Sinon fuite entre boutiques.122123## Argument optionnel124125`/symfony:form-type ProductType Product` — génère `ProductType` mappé sur `Product`.126127`/symfony:form-type src/Form/OrderType.php` — audit d'un type existant (types explicites, options, `data_class`).128129`/symfony:form-type` sans argument — demande l'entité et le cas d'usage.