Skip to Content

Chapitre 2.4 — Les attributs

Partie 2 : PHP moderne — Chapitre 4/8 Version de référence : PHP 8.0+ (attributs) pour Symfony 7.4.

⏱️ TL;DR

  • Un attribut (PHP 8.0) est une métadonnée structurée attachée à une déclaration (classe, méthode, propriété, paramètre, constante) : #[Route('/bookings')].
  • Ils remplacent les annotations (les @Route dans les commentaires de l’ère Symfony 5). Différence de fond : un attribut est un vrai élément du langage, validé par l’IDE et le moteur — pas une chaîne dans un commentaire.
  • Un attribut, seul, ne fait rien. C’est de la donnée passive. Un autre code (Symfony, Doctrine) la lit via l’API de réflexion et agit en conséquence.
  • Écrire le sien = une classe annotée #[\Attribute]. On le lit avec ReflectionClass::getAttributes().
  • Attributs natifs utiles : #[\Override] (8.3), #[\Deprecated] (8.4).
  • Attention au faux-ami : les décorateurs TS s’exécutent ; les attributs PHP sont passifs (lus à la demande). Ce n’est pas la même mécanique.

🎯 Objectifs

  • Lire n’importe quel attribut Symfony/Doctrine sans être dérouté par la syntaxe #[...].
  • Comprendre pourquoi un attribut ne « fait » rien tout seul, et qui le lit.
  • Écrire un attribut personnalisé et le lire via la réflexion.
  • Distinguer attribut PHP et décorateur TypeScript.

1. La syntaxe et l’idée

Un attribut se place juste au-dessus (ou avant) la déclaration qu’il qualifie, entre #[ et ] :

declare(strict_types=1); use Symfony\Component\Routing\Attribute\Route; class BookingController { #[Route('/bookings/{id}', name: 'booking_show', methods: ['GET'])] public function show(int $id): Response { /* … */ } }

#[Route(...)] attache à la méthode show() l’information « cette méthode répond à l’URL /bookings/{id} en GET ». En soi, cette ligne ne route rien : c’est une étiquette. C’est le composant Routing de Symfony qui, au démarrage, parcourt vos classes, lit ces attributs et construit la table de routage.

⚠️ Piège mental : ne cherchez pas « ce que fait #[Route] » dans l’attribut. L’attribut ne fait rien ; il décrit. Le comportement vient du lecteur (ici le Routing). C’est la clé pour comprendre tout Symfony moderne : le code est déclaratif (« voici les faits »), et des composants interprètent ces faits.

2. Où peut-on mettre un attribut ?

Sur presque toutes les déclarations :

#[ORM\Entity] // sur une classe final class Booking { #[ORM\Column(length: 255)] // sur une propriété private string $customerEmail; #[Route('/x')] // sur une méthode public function x( #[Autowire('%kernel.project_dir%')] // sur un paramètre string $projectDir, ): void {} }

Un même élément peut porter plusieurs attributs (empilés) :

#[ORM\Column] #[Assert\NotBlank] #[Assert\Email] private string $email;

Ici : Doctrine lira #[ORM\Column] (persistance), le Validator lira #[Assert\NotBlank] et #[Assert\Email] (validation). Chaque composant ne lit que ses propres attributs et ignore les autres.

3. Annotations → attributs : ce qui a changé depuis Symfony 5

À votre époque, la même information s’écrivait dans un commentaire :

// Symfony 5 / Doctrine — ANNOTATIONS (dans un commentaire, style ancien) /** * @ORM\Entity * @ORM\Table(name="bookings") */ class Booking { /** * @ORM\Column(type="string", length=255) * @Assert\NotBlank */ private $customerEmail; }
// Symfony 7.4 — ATTRIBUTS (natifs) #[ORM\Entity] #[ORM\Table(name: 'bookings')] class Booking { #[ORM\Column(length: 255)] #[Assert\NotBlank] private string $customerEmail; }

Ce que ça change concrètement :

AspectAnnotations (SF5)Attributs (SF7.4)
NatureChaîne dans un commentaire /** */Élément du langage
DépendanceRequiert doctrine/annotationsAucune (natif PHP)
IDEColoration/validation partielleAutocomplétion + vérif complètes
ErreursDétectées au runtimeDétectées plus tôt (syntaxe PHP)
RefactoringFragile (texte)Sûr (l’IDE suit)

Depuis Symfony 5 : c’est le changement visuel le plus frappant. Si vous rouvrez un projet Symfony 7.4 en pensant y voir des @ORM\Column dans des commentaires, vous trouverez des #[ORM\Column]. Les annotations sont dépréciées/supprimées de l’usage courant ; le support du package doctrine/annotations disparaît. Tout est en attributs.

4. Un attribut est juste une classe

Démystification : #[Route(...)] correspond à une classe Symfony\Component\Routing\Attribute\Route, marquée elle-même #[\Attribute]. Écrire #[Route('/x', name: 'y')] revient (conceptuellement) à instancier cette classe avec ces arguments — mais seulement quand quelqu’un la lit.

Écrivons le nôtre. Imaginons un attribut #[AuditLog] pour marquer les méthodes à tracer :

declare(strict_types=1); namespace App\Attribute; use Attribute; #[Attribute(Attribute::TARGET_METHOD)] // ← où cet attribut a le droit de se poser final class AuditLog { public function __construct( public string $action, public bool $sensitive = false, ) {} }

On l’utilise :

use App\Attribute\AuditLog; class BookingController { #[AuditLog(action: 'booking.cancel', sensitive: true)] public function cancel(int $id): Response { /* … */ } }

5. Lire un attribut : l’API de réflexion

Rien ne se passe tant qu’un code ne lit pas l’attribut. La lecture utilise la réflexion (introspection des classes à l’exécution) :

use ReflectionMethod; use App\Attribute\AuditLog; $method = new ReflectionMethod(BookingController::class, 'cancel'); foreach ($method->getAttributes(AuditLog::class) as $attribute) { $audit = $attribute->newInstance(); // instancie App\Attribute\AuditLog echo $audit->action; // 'booking.cancel' echo $audit->sensitive; // true }

getAttributes() renvoie les attributs bruts ; newInstance() construit réellement l’objet avec ses arguments. Dans la vraie vie, vous n’écrirez presque jamais ce code de lecture vous-même : Symfony, Doctrine, le Validator le font pour vous. Mais comprendre le mécanisme démystifie toute la « magie » du framework.

6. Attributs natifs de PHP à connaître

  • #[\Override] (8.3) : déclare qu’une méthode surcharge un parent. Si ce n’est pas le cas (faute de frappe dans le nom), le moteur lève une erreur. Filet de sécurité précieux.
    #[\Override] public function getName(): string { /* … */ } // erreur si le parent n'a pas getName()
  • #[\Deprecated] (8.4) : marque une fonction/méthode comme dépréciée ; l’appeler émet un avertissement.
  • #[\AllowDynamicProperties] : réautorise les propriétés dynamiques (interdites par défaut depuis 8.2) — à éviter, mais utile à reconnaître.

7. Attribut PHP vs décorateur TypeScript (le faux-ami)

Vous allez être tenté d’assimiler #[...] aux décorateurs TS (@Injectable()). C’est une bonne intuition visuelle, mais la mécanique diffère :

Décorateur TypeScriptAttribut PHP
NatureUne fonctionUne classe (donnée)
ExécutionS’exécute quand la classe est définieNe s’exécute pas ; lu à la demande
EffetPeut modifier la cible immédiatementAucun effet en soi ; un lecteur agit
LectureIntégrée au décorateurSéparée, via réflexion

Autrement dit : un décorateur TS fait quelque chose au moment de la définition ; un attribut PHP est une étiquette inerte qu’un tiers interprète plus tard. Cette passivité est voulue : elle garde les métadonnées déclaratives et laisse chaque composant décider quand et comment les lire (souvent au cache-warmup, une seule fois, pas à chaque requête — d’où d’excellentes performances).

💡 Pour un dev Next.js : pensez aux attributs comme à des annotations JSON Schema / Zod attachées au code : elles décrivent des contraintes, et un moteur (le validateur) les applique. La description et l’application sont séparées — c’est exactement l’esprit des attributs.

✏️ Exercices

Exercice 1. Écrivez un attribut #[Throttle] applicable aux méthodes, avec deux paramètres : limit (int) et seconds (int, défaut 60).

✅ Solution

declare(strict_types=1); namespace App\Attribute; use Attribute; #[Attribute(Attribute::TARGET_METHOD)] final class Throttle { public function __construct( public int $limit, public int $seconds = 60, ) {} }

Usage : #[Throttle(limit: 5, seconds: 30)] au-dessus d’une méthode.

Exercice 2. Vrai ou faux : « Poser #[Throttle(limit: 5)] sur une méthode limite automatiquement son débit. » Expliquez.

✅ Solution

Faux. Un attribut est une métadonnée passive : #[Throttle(...)] ne limite rien tout seul. Il faut un code lecteur (par ex. un event listener qui, à chaque requête, lit l’attribut du contrôleur via réflexion et applique un rate limiter). L’attribut décrit l’intention ; un autre composant l’exécute.

Exercice 3. Un même champ porte #[ORM\Column], #[Assert\NotBlank] et #[Groups(['read'])]. Qui lit quoi ?

✅ Solution

Chaque composant lit uniquement ses attributs : Doctrine lit #[ORM\Column] (mapping en base), le Validator lit #[Assert\NotBlank] (règle de validation), le Serializer (utilisé par API Platform) lit #[Groups(['read'])] (quels champs exposer). Les composants s’ignorent mutuellement ; c’est ce qui permet d’empiler proprement plusieurs préoccupations sur une même propriété.

🧠 Quiz de révision

1. Qu’est-ce qu’un attribut, en une phrase ?

Une métadonnée structurée (en réalité une instance de classe) attachée à une déclaration PHP, lue à la demande via la réflexion, qui décrit sans agir.

2. Un attribut « fait »-il quelque chose par lui-même ?

Non. Il est passif. C’est un lecteur (Symfony, Doctrine, Validator…) qui l’interprète via réflexion et déclenche le comportement.

3. Quelle dépendance les annotations Symfony 5 nécessitaient-elles, et qu’en est-il des attributs ?

Les annotations nécessitaient doctrine/annotations (parser de commentaires). Les attributs sont natifs au langage : aucune dépendance, validés par l’IDE et le moteur.

4. Comment lit-on un attribut par le code ?

Via la réflexion : ReflectionClass/ReflectionMethod::getAttributes(NomAttribut::class) puis ->newInstance() pour obtenir l’objet.

5. Différence essentielle entre un décorateur TS et un attribut PHP ?

Le décorateur TS s’exécute (c’est une fonction qui peut modifier la cible) ; l’attribut PHP est passif (une classe/donnée) et n’a d’effet que lorsqu’un tiers le lit.


Prochain chapitre : 2.5 — Classes modernes — promotion de constructeur, arguments nommés, match, nullsafe, first-class callables, readonly, traits.