Aller au contenu principal
Retour au blog

Formulaires Symfony 8.2 par attributs : #[AsFormType]

Symfony 8.2 déclare un formulaire directement sur ton DTO avec #[AsFormType] et #[FormField]. Usage, imbrication, limites et quand garder une classe FormType.

Flavien Métivier · 9 min de lecture
Sommaire · 8 parties

#[AsFormType] est un attribut PHP de Symfony 8.2 qui transforme une classe DTO en type de formulaire : chaque propriété marquée #[FormField] devient un champ. Annoncée le 17 septembre 2026 sur le blog officiel, contribution de Benjamin Georgeault (PR #60563), la fonctionnalité arrive dans Symfony 8.2, dont la sortie est prévue en novembre 2026.

Compte les classes de ton dossier src/Form/. Pour chacune, demande-toi ce qu'elle contient vraiment. Dans beaucoup d'applications, la réponse tient en une liste de champs, un buildForm() de six lignes et un configureOptions() qui ne fait que renseigner data_class. Le DTO porte déjà les propriétés et leurs types ; le FormType les recopie, avec une indirection de plus à naviguer.

Symfony 8.2 supprime cette duplication pour les cas simples. Point important avant d'aller plus loin : les classes FormType ne sont pas dépréciées. La PR elle-même parle de sucre syntaxique pour les formulaires simples. Le choix t'appartient, autant le faire en connaissance de cause. Cet article fait partie de notre série sur Symfony 8.2, dont le dernier épisode portait sur l'outbox et le claim check de Messenger.

Déclarer un formulaire sur un DTO avec #[FormField]

Le principe tient en deux attributs. #[AsFormType] marque la classe et porte les options du formulaire entier. #[FormField] marque chaque propriété qui devient un champ ; sans type explicite, Symfony le devine, comme avec $builder->add(). Voici l'exemple du billet :

php
// src/Dto/UserDto.php
namespace App\Dto;

use Symfony\Component\Form\Attribute\AsFormType;
use Symfony\Component\Form\Attribute\FormField;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;

#[AsFormType(options: ['label' => 'User'])]
class UserDto
{
    // no type given: it's guessed, as with $builder->add()
    #[FormField]
    public ?string $name = null;

    #[FormField(EmailType::class)]
    public ?string $email = null;

    #[FormField(TextareaType::class, ['label' => 'Bio'])]
    public ?string $bio = null;

    // the form field is called "publicName" and its value is mapped
    // to this property through the "property_path" option
    #[FormField(name: 'publicName')]
    public ?string $internalName = null;
}

Quatre cas apparaissent dans ce seul DTO : le type deviné pour $name, le type explicite (EmailType::class), les options en second argument, et un nom de champ différent de la propriété grâce à name:, mappé via property_path. Côté contrôleur, le nom de la classe sert de nom de type, partout où Symfony en attend un :

php
// src/Controller/UserController.php
$form = $this->createForm(UserDto::class, $userDto);

Par rapport à une classe UserType classique, disparaissent la classe elle-même, son configureOptions() et le data_class, que la PR rend implicite et interdit de redéfinir. Le champ, son type, ses options et la propriété vivent au même endroit. Comme les contraintes de validation sont elles aussi des attributs sur les propriétés, un DTO peut porter ses champs et ses règles côte à côte. Une fois le DTO validé, tu peux le projeter vers l'entité avec le mapping DTO natif d'ObjectMapper : les deux fonctionnalités se complètent.

Imbriquer des formulaires : le type doit être explicite

Le même mécanisme couvre les sous-formulaires. Un DTO annoté #[AsFormType] peut servir de type de champ dans un autre :

php
// src/Dto/AddressDto.php
namespace App\Dto;

use Symfony\Component\Form\Attribute\AsFormType;
use Symfony\Component\Form\Attribute\FormField;

#[AsFormType]
class AddressDto
{
    #[FormField]
    public ?string $street = null;

    #[FormField]
    public ?string $city = null;
}
php
// src/Dto/CustomerDto.php
namespace App\Dto;

use Symfony\Component\Form\Attribute\AsFormType;
use Symfony\Component\Form\Attribute\FormField;

#[AsFormType]
class CustomerDto
{
    #[FormField]
    public ?string $name = null;

    // the type must be explicit: type guessing doesn't know
    // that AddressDto is a form type
    #[FormField(AddressDto::class)]
    public AddressDto $address;

    public function __construct()
    {
        $this->address = new AddressDto();
    }
}

Le piège est dans le commentaire : le devineur de types ne sait pas qu'AddressDto est un form type. Un #[FormField] sans argument sur $address ne produira pas le sous-formulaire attendu. Note aussi l'initialisation dans le constructeur : la propriété n'est pas nullable, il faut un objet valide pour que le formulaire lise la donnée. Fais du type explicite sur les DTO imbriqués une règle de relecture.

Pourquoi les erreurs apparaissent à la compilation du conteneur

Symfony lit ces attributs à la compilation du conteneur et transforme chaque classe en service de form type ordinaire. Selon la PR, chaque classe est compilée en une définition faite de littéraux : aucune réflexion à l'exécution. Conséquence directe : une déclaration invalide échoue au build, pas à la première requête qui affiche le formulaire. La PR cite un type de champ inexistant, un nom de champ dupliqué, name combiné à l'option property_path, ou un data_class dans les options. Un cache:warmup en CI devient un vrai garde-fou.

La condition, c'est que le DTO soit scanné par le conteneur avec l'autoconfiguration activée. Le config/services.yaml par défaut le fait pour src/, mais il exclut src/Entity/. Un #[AsFormType] posé sur une entité Doctrine est donc ignoré avec la configuration standard. L'esprit de la fonctionnalité, ce sont des DTO dans src/Dto/. Le squelette usuel, à comparer avec ton propre fichier :

yaml
# config/services.yaml (squelette usuel : vérifie le tien)
services:
    _defaults:
        autowire: true
        autoconfigure: true   # requis pour que #[AsFormType] soit détecté

    App\:
        resource: '../src/'
        exclude:
            - '../src/DependencyInjection/'
            - '../src/Entity/'   # un #[AsFormType] placé ici est ignoré
            - '../src/Kernel.php'

La PR mentionne aussi que debug:form liste ces types dans une section « Data class form types », pratique pour vérifier qu'un DTO a bien été détecté.

Passer des services et des options : scalaires, enums et Reference

Migration Symfony en vue ?

Bear Upgrade migre votre codebase 2,5× à 4,5× moins cher qu'une ESN, selon sa maturité.

Estimer ma migration →

Les valeurs d'options doivent être des scalaires, des tableaux ou des enums. Les closures sont rejetées, même si PHP 8.5 les accepte dans les attributs. Pour injecter un service, passe un objet Reference, que le conteneur résout comme d'habitude :

php
// src/Dto/PostDto.php
namespace App\Dto;

use App\Form\TagChoiceLoader;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\Form\Attribute\AsFormType;
use Symfony\Component\Form\Attribute\FormField;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;

#[AsFormType]
class PostDto
{
    #[FormField(ChoiceType::class, [
        'choice_loader' => new Reference(TagChoiceLoader::class),
    ])]
    public array $tags = [];
}

Détail utile : les chaînes restent littérales. Un % dans 'label' => '100% free' n'est pas interprété comme un paramètre du conteneur. Tout ce qui s'exprime d'ordinaire par une closure (choice_label, query_builder) sort donc des attributs, ou passe par un service.

Ajouter de la logique avec une form type extension

Les attributs décrivent des champs, rien d'autre. Dès qu'il te faut des événements de formulaire, des data transformers ou des champs calculés à l'exécution, tu passes par une extension de type qui cible la classe de données. Ça fonctionne parce que le type dérivé garde le DTO comme identité :

php
// src/Form/Extension/UserDtoExtension.php
namespace App\Form\Extension;

use App\Dto\UserDto;
use Symfony\Component\Form\AbstractTypeExtension;
use Symfony\Component\Form\FormBuilderInterface;

class UserDtoExtension extends AbstractTypeExtension
{
    public static function getExtendedTypes(): iterable
    {
        yield UserDto::class;
    }

    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        // add form events, transformers, dynamic fields, etc.
    }
}

L'héritage suit une règle précise : quand une classe #[AsFormType] en étend une autre, l'ancêtre le plus proche qui porte aussi l'attribut devient le type parent, et ses champs sont hérités. Sans ancêtre annoté, le parent est FormType.

Quand garder une classe FormType classique

Le billet est clair : les classes restent la voie dès qu'un formulaire demande plus qu'une liste de champs. Garde une classe FormType quand :

  • le formulaire injecte des services pour construire ses champs ou calcule ses options à l’exécution ;
  • il enchaîne des événements (PRE_SET_DATA…) ou des data transformers ;
  • il repose sur des closures dans les options ;
  • il est branché directement sur une entité Doctrine.

Surveille aussi le seuil de lisibilité : un DTO annoté plus trois extensions n'est pas plus simple qu'un FormType. La logique n'est pas supprimée, elle est déplacée. À noter enfin, plusieurs PR ont été mergées dans la branche 8.2 après le billet, notamment un devineur de type basé sur TypeInfo (#66772) et le mapping des champs vers le constructeur pour les classes readonly (#66731). Elles peuvent encore évoluer avant la version stable, et la documentation officielle n'est pas encore publiée.

Migrer un FormType simple vers un DTO annoté

  • Choisis un FooType qui ne contient que des ->add() sur un DTO.
  • Ajoute #[AsFormType] sur le DTO, avec les options qui étaient dans configureOptions() (hors data_class).
  • Pose #[FormField] sur chaque propriété, avec un type explicite pour les DTO imbriqués.
  • Remplace FooType::class par FooDto::class dans createForm(), les entry_type de CollectionType et tes tests basés sur TypeTestCase, puis supprime l’ancienne classe.

Les contraintes de validation, elles, restent sur le DTO ; si tu veux aller plus loin sur ce terrain, notre article sur les contraintes Validator avec closures inline montre jusqu'où on peut les pousser.

Ma lecture

Les formulaires par attributs retirent une classe là où elle n'apportait qu'une liste de champs. C'est tout, et c'est déjà utile : moins de fichiers à ouvrir, des erreurs de déclaration détectées au build. Si ton équipe applique « pas de formulaire sur les entités, DTO partout », tu y gagnes directement : formulaires de contact, de profil, d'adresse et sous-formulaires réutilisés sont les candidats évidents.

Ne migre pas l'existant en bloc. Rien n'est déprécié, donc rien ne presse : commence par les nouveaux formulaires, et réécris un FormType existant seulement quand tu le modifies déjà. Ajoute deux lignes à ta checklist de relecture : type explicite sur les DTO imbriqués, dossier des DTO bien scanné par services.yaml.

Ce genre de confort reste hors de portée tant qu'une application a plusieurs versions de retard. C'est le travail de Bear Upgrade : faire avancer ta codebase version après version, chaque étape arrivant en Merge Request testée que ton équipe relit et fusionne à son rythme, pour profiter des nouveautés de Symfony le jour où tu le décides.

Cet article vous a été utile ? Partagez-le.

Portrait de Flavien Métivier, fondateur de The Bearded Bear : barbe longue, lunettes, sur fond sombre

Écrit par

Flavien Métivier

Expert PHP/Symfony indépendant depuis 2014, ancien CTO de Kartable et d'Extrabat. Plus de 80 projets menés, de l'audit à la migration.

Mon parcours

À lire ensuite

Migration Symfony en vue ?

Bear Upgrade migre votre codebase 2,5× à 4,5× moins cher qu'une ESN, selon sa maturité.

Newsletter Bear Bytes

Veille Claude Code, Symfony et PHP, retours d'expérience terrain et conseils techniques, directement dans votre boîte mail.