Un attribut PHP ne fait rien. C'est une classe qu'on pose sur une autre, et tout le travail est dans le code qui vient la relire. Voilà comment on écrit les deux moitiés, et ce que le compilateur refuse de vérifier à notre place.
Trois attributs faits maison
Le src/ de ce blog déclare trois attributs à lui : #[Breadcrumb], #[AsBlockDefinition], #[BlockAttr]. Ils sont posés 113 fois, sur 13 contrôleurs, 26 composants Twig et 74 propriétés.
Aucun des trois ne fait quoi que ce soit.
Ce sont des classes presque vides, sans une ligne de logique, et c'est normal : un attribut ne s'exécute jamais. Il se pose, il attend, et le travail est intégralement dans le code qui vient le relire. Ce billet montre les deux moitiés sur du code qui tourne ici, puis la chose que la doc laisse au lecteur : ce que PHP refuse de vérifier à notre place.
Le détour par les annotations vaut d'être fait, parce qu'il explique la forme de tout le reste.
Un attribut, c'est une classe
Avant PHP 8, le routage, la validation et le mapping ORM tenaient dans des commentaires.
Sur le même sujet
PHP 8.0 : la révolution qui a tout changé
/**
* @Route("/api/posts", methods={"POST"})
*/
public function create() {}L'interpréteur ignorait ces lignes intégralement. Pour leur donner un sens, il fallait une bibliothèque tierce, doctrine/annotations, qui relisait le fichier source et passait le bloc de commentaire dans son propre lexeur puis son propre parseur. Ce n'était pas du bricolage à coups d'expressions régulières, contrairement à ce qu'on lit souvent : c'était un vrai analyseur syntaxique, avec sa grammaire. Le problème n'était pas sa qualité, c'était son existence. Il fallait un second langage, parsé par un second outil, pour dire des choses sur le premier.
La version PHP 8 dit la même chose, en moins de signes :
#[Route('/api/posts', methods: ['POST'])]
public function create() {}Le gain tient en une phrase : Route est devenu une classe. Elle a un espace de noms, elle s'importe, elle a un constructeur avec des paramètres typés. Oublier le use déclenche une erreur fatale au chargement. Se tromper de nom d'argument nommé aussi. Les outils d'analyse statique la voient, et un renommage de classe la suit.
C'est le point de bascule, et il vaut mieux que « métadonnées natives » : les informations sur le code ont quitté les commentaires pour rejoindre le système de types. Sur ce blog, doctrine/annotations a complètement disparu du composer.lock.
Déclarer : la cible, et rien d'autre
Écrire son propre attribut demande une classe et un attribut sur cette classe. Le plus court des trois, ici, sert à poser un fil d'Ariane sur un contrôleur :
// src/Attribute/Breadcrumb.php
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)]
final readonly class Breadcrumb
{
/**
* @param list<array{label: string, route?: string, params?: array<string, mixed>}> $items
*/
public function __construct(
public array $items = [],
) {}
}C'est tout. Un constructeur, des propriétés promues publiques, et #[\Attribute] qui déclare où la classe a le droit d'être posée. Les propriétés sont publiques parce que le code qui relira l'attribut lit directement dessus, sans accesseurs.
La cible est le seul vrai choix de conception. Les trois attributs de ce blog en couvrent trois formes différentes. #[AsBlockDefinition] est en TARGET_CLASS : il marque un composant Twig comme bloc d'éditeur et porte ce dont le menu a besoin, un libellé, une icône, une catégorie. #[BlockAttr] est en TARGET_PROPERTY, et son commentaire dit pourquoi : la propriété est l'attribut de données, donc le schéma vit là où la donnée vit. #[Breadcrumb] accepte les deux, classe et méthode, parce qu'un contrôleur invokable porte le sien sur la classe quand un contrôleur à plusieurs actions le porte sur la méthode.
À l'usage, ça donne une ligne sur un contrôleur :
// src/Controller/Content/CategoryAction.php
#[Breadcrumb(items: [['label' => 'Catégories', 'route' => CategoriesAction::class]])]Posée là, cette ligne ne produit rien. Personne ne l'a encore lue.
Relire : là où la doc s'arrête
La seconde moitié est celle qu'on écrit rarement, et c'est la seule qui exécute quelque chose. Sur ce blog, un écouteur d'événement récupère l'attribut du contrôleur au moment où Symfony a résolu ses arguments :
// src/EventListener/Seo/BreadcrumbAttributeSubscriber.php
private function resolveAttribute(mixed $controller): ?Breadcrumb
{
if (\is_array($controller) && \count($controller) === 2 && \is_object($controller[0]) && \is_string($controller[1])) {
$reflectionClass = new \ReflectionClass($controller[0]);
$method = $controller[1];
} elseif (\is_object($controller) && !$controller instanceof \Closure) {
$reflectionClass = new \ReflectionClass($controller);
$method = '__invoke';
} else {
return null;
}
if ($reflectionClass->hasMethod($method)) {
$onMethod = $reflectionClass->getMethod($method)->getAttributes(Breadcrumb::class);
if ($onMethod !== []) {
return $onMethod[0]->newInstance();
}
}
$onClass = $reflectionClass->getAttributes(Breadcrumb::class);
return $onClass !== [] ? $onClass[0]->newInstance() : null;
}Trois choses là-dedans ne sont dans aucun tutoriel.
D'abord, getAttributes() ne rend pas l'objet attendu. Il rend un tableau de ReflectionAttribute, des enveloppes qui connaissent le nom de la classe et ses arguments sans les avoir instanciés. Rien n'existe tant qu'on n'appelle pas newInstance(), et c'est là que le constructeur tourne enfin. C'est la vraie réponse à la question du coût : le compilateur PHP range les attributs dans le code compilé, la réflexion les décrit sans les construire, et on ne paie l'instanciation que sur ceux qu'on demande. Une page qui lit un attribut n'instancie pas les quatre-vingts autres du projet.
Ensuite, la précédence est à notre charge. Le code ci-dessus regarde la méthode d'abord, la classe ensuite. PHP ne connaît pas cet ordre, ne le suggère pas, et n'a aucun avis dessus. Dès qu'on autorise deux cibles, on écrit soi-même laquelle gagne.
Enfin, il faut savoir ce qu'on introspecte. Un contrôleur Symfony résolu est soit un couple objet-méthode, soit un objet invokable. Les deux branches du début ne sont pas de la paranoïa, ce sont les deux formes réelles, plus une sortie pour tout le reste.
Un attribut ne promet rien
Voilà le point qui coûte cher, et il a une conséquence directe sur la façon dont on conçoit.
Une interface est un contrat que PHP fait respecter. Une classe qui déclare implements BlockDefinitionInterface sans écrire les méthodes ne se charge pas. Un attribut n'est rien de tel. Rien n'oblige une classe à en porter un, aucun moteur ne s'en émeut, et l'oubli ne se voit qu'au moment où le code de lecture cherche quelque chose qui n'est pas là.
Sur ce blog, un bloc d'éditeur doit porter les deux : l'interface et l'attribut. L'interface se garde toute seule. Pour l'autre, j'ai fini par lever une exception au démarrage plutôt que de découvrir le trou en production :
// src/Block/BlockDefinitionRegistry.php
$blockAttr = $reflection->getAttributes(AsBlockDefinition::class)[0] ?? null;
if ($blockAttr === null) {
throw new \LogicException(\sprintf(
'Class %s implements BlockDefinitionInterface but is missing the #[AsBlockDefinition] attribute.',
$reflection->getName(),
));
}Le message dit exactement la dissymétrie : la classe implémente bien l'interface, et il lui manque l'attribut. Le premier est vérifié par le langage, le second par cette poignée de lignes. Une interface, PHP la vérifie. Un attribut, il se contente de le transporter.
Une information obligatoire a sa place dans une signature de méthode. Quand elle est facultative, descriptive, ou destinée à un outil extérieur, l'attribut fait l'affaire, à condition d'écrire la garde qui va avec.
Ce que Symfony pose déjà dans le projet
Le framework applique le même schéma à grande échelle, et la moitié qui relit est déjà écrite. Sur ce blog : 143 #[Autowire], 80 #[Route], 71 #[Override], 11 #[MapEntity], un seul #[AsDecorator].
Pour aller plus loin
L'injection de dépendance, ou comment être fainéant avec élégance
#[Autowire] injecte ce que le typage seul ne suffit pas à désigner, un paramètre de conteneur ou un service précis :
// src/EventListener/OAuth2/OAuthRateLimitListener.php
#[Autowire(service: 'limiter.oauth_token')]Un mot pour éviter une confusion répandue : cette forme ne décore rien du tout, elle nomme le service à injecter. La décoration est un autre attribut, et il porte son nom :
// src/Security/Mcp/AudienceValidatingBearerTokenValidator.php
#[AsDecorator('league.oauth2_server.bearer_token_validator')]
final class AudienceValidatingBearerTokenValidator extends BearerTokenValidator#[MapEntity] convertit un paramètre d'URL en entité et rend un 404 quand rien ne correspond, ce qui retire une requête et une garde du corps du contrôleur. #[Override] est le seul du lot à ne rien apporter au framework : il sert au compilateur, qui vérifie que la méthode surchargée existe vraiment chez le parent. Soixante et onze occurrences ici, pour un coût d'écriture nul et une erreur fatale le jour où une signature bouge en amont.
Les attributs du conteneur méritent leur propre développement, et ils l'ont dans L'injection de dépendance, ou comment être fainéant avec élégance.
La moitié du chemin
Les attributs ont sorti les métadonnées des commentaires pour les mettre dans le système de types, et c'est un vrai gain. Le nom de la classe est vérifié, ses arguments sont typés, un renommage se propage, l'analyse statique suit.
Le chemin s'arrête à mi-course, et c'est ce que le mot « natif » masque. Le langage garantit que l'attribut existe et qu'il est bien formé. Il ne garantit à personne qu'il a été posé, ni qu'il a été posé là où il fallait, ni que quelqu'un viendra le relire. Cette vérification vit dans le code de lecture, ou elle ne vit nulle part.
Ce qui a changé depuis PHP 8, c'est donc l'outil, pas la responsabilité. On a gagné un compilateur qui lit nos métadonnées ; on n'a pas gagné quelqu'un qui décide ce qu'elles engagent.
Une question à se poser sur son propre code : combien de vos attributs ont un morceau de code qui les relit, et combien ne sont que des commentaires avec une syntaxe plus jolie ?
Une coquille, une erreur dans ce billet ? Signale-la-moi.
Ce billet est publié sous licence Creative Commons BY-NC-SA 4.0 (attribution, pas d'usage commercial, partage dans les mêmes conditions).