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
@Routedans 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 avecReflectionClass::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 :
| Aspect | Annotations (SF5) | Attributs (SF7.4) |
|---|---|---|
| Nature | Chaîne dans un commentaire /** */ | Élément du langage |
| Dépendance | Requiert doctrine/annotations | Aucune (natif PHP) |
| IDE | Coloration/validation partielle | Autocomplétion + vérif complètes |
| Erreurs | Détectées au runtime | Détectées plus tôt (syntaxe PHP) |
| Refactoring | Fragile (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\Columndans des commentaires, vous trouverez des#[ORM\Column]. Les annotations sont dépréciées/supprimées de l’usage courant ; le support du packagedoctrine/annotationsdisparaî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 TypeScript | Attribut PHP | |
|---|---|---|
| Nature | Une fonction | Une classe (donnée) |
| Exécution | S’exécute quand la classe est définie | Ne s’exécute pas ; lu à la demande |
| Effet | Peut modifier la cible immédiatement | Aucun effet en soi ; un lecteur agit |
| Lecture | Intégrée au décorateur | Sé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.