Attributs PHP 8: mon bilan après un an de migration
La différence paraît cosmétique. Elle ne l’est pas.
Après un an de migration, le constat est net: remplacer les annotations DocBlock par des attributs PHP 8 stabilise le code de framework, réduit les conventions implicites et élimine une couche de parsing. Mais une migration d’attributs PHP 8 ne se résume pas à un remplacement syntaxique. Elle touche au runtime, à Composer, au cache de métadonnées, aux bundles Symfony, au mapping Doctrine et, surtout, à la version de PHP réellement exécutée.
Le piège classique est de faire passer le dépôt en PHP 8.1, de voir la CI au vert, puis de déployer sur un pool FPM resté en PHP 8.0 — ou pire, sur une image qui ignore la plateforme simulée dans composer.json. Le code compile. L’application casse au premier chemin de réflexion qui instancie l’attribut.
Les attributs ne sont pas des commentaires modernisés. Ce sont des objets potentiels, lus tardivement par le runtime.
Des DocBlocks tolérants aux métadonnées exécutables
Les annotations historiques vivent dans des commentaires. PHP les ignore. Symfony, Doctrine ou un lecteur tiers les récupère, tokenize leur contenu, interprète une syntaxe documentaire, puis construit des métadonnées internes.
Les attributs, introduits avec PHP 8.0, déplacent cette mécanique dans le langage. Leur syntaxe repose sur #[...]. Les arguments sont positionnels ou nommés. Ils doivent être des littéraux ou des expressions constantes. La classe qui porte l’attribut doit elle-même être déclarée avec #[Attribute].
Cette bascule produit trois effets directs.
- La syntaxe est contrôlée par PHP. Une coquille dans une annotation pouvait rester un commentaire inerte jusqu’au passage du lecteur concerné. Un attribut mal formé relève du parseur ou de la réflexion.
- Les imports deviennent significatifs.
use Symfony\Component\Routing\Attribute\Route;n’est pas du décor. Un nom court ambigu, une classe déplacée ou un alias erroné dégrade le comportement au runtime. - La cible devient contractuelle. Une classe d’attribut peut limiter son emploi aux classes, méthodes, propriétés, paramètres, constantes ou fonctions. Poser une métadonnée de propriété sur une méthode n’est plus une convention floue: c’est une violation de cible.
- La répétition doit être déclarée. Un attribut n’est pas répétable par défaut. Pour accumuler plusieurs occurrences, il faut explicitement utiliser
Attribute::IS_REPEATABLE.
Le gain principal n’est donc pas une accélération automatique. Il est structurel: le langage connaît la forme de la métadonnée, son emplacement et ses arguments. En revanche, prétendre que les performances des attributs PHP 8 sont universellement meilleures serait faux. Sur une application Symfony ou Doctrine, le résultat dépend du cache de métadonnées, de l’OPcache, du mode de chargement des classes et du volume de réflexion au démarrage.
Une migration propre commence par distinguer les DocBlocks qui sont réellement des annotations de ceux qui restent nécessaires:
- les commentaires de documentation d’API;
- les génériques compris par PHPStan ou Psalm, comme
@template,@extendsou@var Collection<int, User>; - les contrats de sérialisation consommés par un outil encore fondé sur PHPDoc;
- les annotations de bibliothèques qui n’ont pas de support attribut fiable dans la version installée.
Supprimer tous les DocBlocks au passage est une erreur de refactoring. Un attribut ne remplace pas un système de types génériques que PHP ne possède toujours pas.
Reflection: lire n’est pas instancier
C’est le point qui produit les erreurs de migration les plus coûteuses à diagnostiquer. L’API Reflection sépare la découverte d’un attribut et son instanciation.
ReflectionClass::getAttributes() renvoie des objets ReflectionAttribute. À ce stade, PHP expose la description de l’attribut. L’objet applicatif n’existe pas encore. Il faut appeler ReflectionAttribute::newInstance() pour construire l’instance.
Cette frontière a des conséquences opérationnelles. Une passe de découverte peut sembler saine alors que l’instanciation échouera ensuite: classe d’attribut absente, argument invalide, cible interdite, constructeur incompatible. Un test qui vérifie seulement le nombre d’attributs trouvés ne valide pas le contrat complet.
Le pattern minimal consiste à faire remonter les échecs au moment où le conteneur, le routeur ou le mapper construit réellement ses métadonnées. Dans une couche interne, cela implique de tester les deux niveaux:
1. la présence de l’attribut via getAttributes();
2. la validité de son objet via newInstance().
Le filtrage mérite la même rigueur. getAttributes(Foo::class) recherche par nom exact. Si l’architecture expose une interface ou une classe de base d’attributs, le drapeau ReflectionAttribute::IS_INSTANCEOF évite de figer le consommateur sur une implémentation concrète. Sans ce drapeau, une hiérarchie d’attributs existe dans le code mais reste invisible à la réflexion filtrée.
Le coût CPU ne se décide pas ici sur intuition. Reflection n’est pas gratuite, mais ce n’est pas non plus le goulet d’étranglement systématique qu’on lui prête. La question pertinente est: combien de fois les métadonnées sont-elles analysées par requête, et le framework les met-il en cache? Une application qui reconstruit des centaines de descripteurs à chaque requête a un problème de cycle de vie, pas un problème intrinsèque d’attributs.
getAttributes()prouve qu’un marqueur est présent.newInstance()prouve qu’il est exploitable.
Symfony: le refactoring est simple, les priorités ne le sont pas
Symfony traite les attributs comme les successeurs des annotations. Pour les routes, attributs, YAML et configuration PHP offrent les mêmes fonctionnalités et les mêmes performances. Le choix est donc architectural, pas micro-optimisateur.
Avec Symfony Flex, le routage par attributs est activé par défaut dans de nombreuses configurations. C’est pratique, mais cela modifie l’attention à porter à l’organisation des contrôleurs. Une route devient physiquement collée à la méthode qu’elle expose. La lecture locale s’améliore. La vision globale du routing peut, elle, se disperser si l’arborescence est mal tenue.
Le cas dangereux apparaît lors d’une migration hybride. Symfony donne priorité à #[Route] par rapport à une route définie en YAML ou dans un fichier PHP. Conserver l’ancienne déclaration « au cas où » ne sécurise rien: cela peut changer la route effectivement retenue, son nom, ses contraintes ou ses méthodes HTTP.
| Sujet | Annotation historique | Attribut PHP |
|---|---|---|
| Localisation | DocBlock interprété par Symfony | Déclaration native au-dessus de la classe ou méthode |
| Validation | Dépend du lecteur d’annotations | Syntaxe contrôlée par PHP, validation complète possible à l’instanciation |
| Coexistence YAML/PHP | Configuration externe prévisible | #[Route] prioritaire sur YAML et PHP |
| Lisibilité du contrôleur | Métadonnée compacte mais textuelle | Paramètres nommés plus explicites |
| Risque de migration | Parser, imports et syntaxe documentaire | Doublons de routes, imports, version de runtime |
Autre détail peu visible: si plusieurs classes PHP sont placées dans le même fichier, Symfony ne charge les routes que de la première classe. C’est une contrainte sans intérêt dans un projet PSR-4 propre, mais les bases anciennes contiennent souvent des fichiers composites, des classes de test ou des contrôleurs historiques regroupés par domaine. Le refactoring Symfony vers les attributs révèle alors un défaut de structure que les annotations masquaient.
La méthode fiable n’est pas de convertir contrôleur par contrôleur sans contrôle global. Elle consiste à comparer le routeur généré avant et après migration: noms de routes, chemins, méthodes, host, préfixes et priorités. Le résultat attendu n’est pas « les pages répondent ». Le résultat attendu est un diff nul, sauf modifications explicitement voulues.
Doctrine: le mapping passe, les traits restent un angle mort
Doctrine ORM prend en charge le mapping par attributs depuis la version 2.9. Dans la documentation Doctrine ORM 3.7, l’ancien support des métadonnées par annotations a été retiré. Pour un projet qui prépare Doctrine 3, maintenir une stratégie d’annotations n’est donc pas une position neutre: c’est prolonger une compatibilité dont la sortie est déjà actée.
Le mapping attributaire est direct. #[Entity], #[Table], #[Column], #[Id], #[ManyToOne] déplacent la configuration sur les propriétés et classes concernées. On gagne en proximité entre modèle et mapping. On perd aussi la possibilité de considérer la métadonnée comme une couche textuelle inoffensive.
Les zones qui demandent un audit spécifique sont les suivantes:
- Les traits de mapping. Doctrine recommande la prudence sur les attributs posés dans des traits; ce cas n’est pas officiellement pris en charge. Un
TimestampableTraitdécoré peut sembler fonctionner jusqu’à une évolution du driver de métadonnées ou une classe qui combine plusieurs traits concurrents. - Les héritages et classes abstraites. Les stratégies de discrimination, les
MappedSuperclasset les associations héritées doivent être validés sur le schéma généré, pas seulement sur le chargement des entités. - Les types personnalisés. Un attribut
#[Column(type:...)]n’annule aucune dépendance à l’enregistrement du type Doctrine, ni aux conversions de plateforme MySQL. - Les propriétés promues. Leur concision est séduisante, mais la combinaison constructeur, visibilité, hydratation et attributs de mapping doit rester lisible. L’entité Doctrine n’est pas un DTO.
- Les migrations de schéma. Une entité découverte n’implique pas un schéma identique. Le différentiel de migration reste l’arbitre: colonnes, nullabilité, index, longueurs, contraintes et tables de jointure.
Les annotations imbriquées sont un cas de versionnement plus brutal. PHP 8.1 permet l’emploi d’objets dans les arguments d’attribut; les attributs imbriqués deviennent alors possibles. PHP 8.0 ne permet pas de reproduire intégralement cette structure. Convertir une annotation complexe en syntaxe élégante sur un poste local en 8.2, puis annoncer une compatibilité 8.0, est un défaut de matrice de support.
La version déclarée n’est pas la version exécutée
PHP 8.0 est arrivé en fin de vie le 26 novembre 2023, avec PHP 8.0.30 comme dernière version. PHP 8.1 a atteint sa fin de vie le 31 décembre 2025, avec PHP 8.1.34 comme dernière version. En pratique, une migration faite aujourd’hui ne devrait pas concevoir sa compatibilité autour de ces branches, sauf contrainte d’infrastructure documentée et temporaire.
Le problème reste fréquent parce que Composer donne une impression de contrôle. config.platform.php permet de simuler une version cible pour résoudre les dépendances. C’est utile pour empêcher l’installation d’un package exigeant PHP 8.3 sur un projet contractuellement limité à une autre version. Ce n’est pas une sonde de production.
Composer ne sait pas quelle image Docker est réellement lancée, quelle version le pool PHP-FPM expose derrière Nginx, ni si le binaire CLI utilisé par le déploiement correspond au runtime qui sert les requêtes. La seule vérification utile au déploiement est composer check-platform-reqs, exécutée dans l’environnement final ou dans une image strictement identique.
La séquence de fiabilisation est courte:
1. Fixer une matrice explicite: version PHP minimale, version de Symfony, version de Doctrine ORM, versions des bundles qui lisent les métadonnées.
2. Faire tourner les tests sous la version minimale réellement supportée, pas seulement sous la dernière version de la machine de CI.
3. Exécuter composer check-platform-reqs dans l’artefact ou le conteneur destiné à la production.
4. Vider ou reconstruire les caches de conteneur, de routes et de métadonnées selon le mode de déploiement.
5. Comparer les routes Symfony et le schéma Doctrine avant bascule.
6. Tester l’instanciation des attributs internes, notamment ceux consommés par réflexion hors du chemin HTTP principal.
Cette dernière étape détecte les attributs qui ne cassent ni le cache warmup ni les tests fonctionnels superficiels. Les bibliothèques internes de validation, d’autorisation, de sérialisation ou de bus de messages sont souvent concernées: elles ne passent en réflexion que sur des flux rares.
Ce que la migration change réellement
Un an après, le bilan est moins spectaculaire que les annonces autour de PHP 8, mais plus utile. Les attributs réduisent la distance entre la déclaration et son exécution. Ils rendent les conventions de framework plus explicites. Ils préparent correctement les projets Doctrine à l’abandon des annotations historiques. Ils ne corrigent ni une architecture de métadonnées surchargée, ni des caches mal configurés, ni une politique de versions incohérente.
La décision binaire est simple.
Pour Symfony récent, Doctrine ORM maintenu et une cible PHP encore supportée: à utiliser en production. La migration doit être contrôlée par des diffs de routes, de mapping et par une vérification de plateforme réelle.
Pour une base contrainte par PHP 8.0, des annotations imbriquées, des traits Doctrine décorés ou des packages non audités: à ne pas basculer en production. Pas avant d’avoir traité les incompatibilités. La syntaxe est rapide à convertir. Le runtime, lui, ne négocie pas.




