Les getters et les setters dans Doctrine : on n'en a tous écrits ou générés avec le Maker Bundle au point de plus les voir tellement ils font parti du paysage. Il faut toute de même avouer qu'il est assez gênant de devoir scroller dix écrans pour repérer la règle métier d'un champ. La donnée et son comportement vivent dans le même fichier, mais à 200 lignes d'écart.
C'est ce que les Property Hooks de PHP 8.4 corrigent. La RFC a été votée pour PHP 8.4, sorti le 21 novembre 2024 et Doctrine ORM a officiellement supporté la feature dans la 3.4.0. On va voir pourquoi, ce que la syntaxe permet vraiment, les patterns que j'utilise, et les pièges que l'on peut rencontrer.
Le problème historique des getters/setters
Une entité Doctrine en PHP 8.3 ressemblait à ça :
class User
{
private string $name;
public function getName(): string
{
return $this->name;
}
public function setName(string $name): void
{
$this->name = trim($name);
}
}Sur dix propriétés, on multiplie par dix. La moindre règle métier — un trim, un strtolower, un assert — se retrouve noyée dans la masse, et le lecteur doit scanner toute la classe pour la repérer.
Le problème n'est pas esthétique. Il est cognitif : la donnée et ses règles décrivent une seule chose, mais elles sont dissociées dans le fichier. Quinze ans d'habitude Java masquaient le coût de cette dissociation.
Anatomie d'un Property Hook
Un hook fusionne la déclaration d'une propriété et de ses accesseurs en un seul bloc :
class User
{
public string $name {
get => strtoupper($this->name);
set => trim($value);
}
}Trois mécanismes à intégrer avant tout.
Le paramètre $value est implicite quand le type du paramètre est identique au type de la propriété. Dans le hook ci-dessus, on aurait pu écrire set(string $value) { ... } mais c'est superflu. La RFC encourage la forme courte.
$this->name à l'intérieur du hook référence la backing value, pas le hook lui-même. C'est ce qui rend la propriété « backed » : PHP réserve un emplacement mémoire pour stocker la valeur. Sans cette référence, la propriété devient « virtuelle » (cf. section 4).
La forme expression set => expr; écrit automatiquement le résultat dans la backing value. La forme bloc set { ... } exige une assignation explicite ($this->name = ...;) à l'intérieur. Confondre les deux est l'erreur la plus fréquente lors de la migration — on y revient en section 10.
Les six formes syntaxiques à connaître
Six combinaisons couvrent 95 % des cas. Mémoriser leur signature évite les hésitations.
Get expression seul (lecture transformée)
public string $name {
get => strtoupper($this->name);
}L'écriture reste libre, la lecture passe par le hook.
Set expression seul (écriture transformée)
public string $name {
set => trim($value);
}La lecture est triviale, l'écriture nettoie automatiquement.
Set bloc (logique multi-lignes ou exception)
public float $price = 0.0 {
set {
if ($value < 0.0) {
throw new \InvalidArgumentException('Un prix ne peut pas être négatif.');
}
$this->price = $value;
}
}Dès qu'il y a une exception, un if, ou plusieurs lignes : forme bloc obligatoire.
Get + set ensemble
public string $email = '' {
get => $this->email;
set => strtolower(trim($value));
}Les deux hooks coexistent sans contrainte de forme — on mélange expression et bloc librement.
Type d'argument élargi sur le set
public ContentStatusEnum $status = ContentStatusEnum::DRAFT {
set(ContentStatusEnum|string $value) {
$this->status = $value instanceof ContentStatusEnum
? $value
: ContentStatusEnum::from($value);
}
}Le type du paramètre peut être plus large que celui de la propriété. C'est explicitement autorisé par la RFC, et c'est l'idiome pour absorber les conversions automatiques (string venant d'un formulaire, par exemple).
Get par référence (&get)
public array $items = [] {
&get => $this->items;
}Permet la modification par référence ($obj->items[] = 'x';). À manier avec prudence : c'est précisément ce qui peut bypasser un set hook si on le combine mal (cf. section 10).
Backed vs virtual : la distinction qui structure tout
C'est la notion qui détermine si Doctrine persiste la propriété ou pas.
Une propriété est backed dès lors qu'au moins un de ses hooks référence $this->propriete. PHP réserve alors un slot mémoire pour stocker la valeur. C'est le cas de tous les exemples précédents.
Une propriété est virtuelle si aucun hook ne référence sa propre backing value. PHP ne réserve aucun slot ; la propriété n'existe que par le calcul fait dans le hook. Exemple tiré de Post.php sur ce blog :
public string $statusLabel {
get => match ($this->status) {
ContentStatusEnum::DRAFT => 'Brouillon',
ContentStatusEnum::SCHEDULED => 'Planifié',
ContentStatusEnum::ONLINE => 'Publié',
};
}Pas de $this->statusLabel dans le hook → propriété virtuelle. Doctrine la détecte et l'ignore au moment de la persistance, à condition de ne pas mettre #[ORM\Column] dessus. C'est la règle d'or : #[ORM\Column] sur une virtuelle = erreur de schéma.
Conséquences pratiques :
- Une virtuelle n'est pas query-able en DQL (ce n'est pas une colonne).
- Une virtuelle est recalculée à chaque accès. Pour du cache, on garde un attribut privé (cf.
Post::$readingTimeplus bas). - Une virtuelle peut référencer d'autres propriétés (backed ou virtuelles), mais jamais elle-même — la récursion infinie est immédiate.
La visibilité asymétrique : public private(set)
Deuxième nouveauté de PHP 8.4 que les hooks rendent indispensable. Une propriété peut être publique en lecture et privée en écriture (ou protected(set)), ce qui supprime le besoin du couple private $x + public getX().
Sur ce blog, mes timestamps utilisent ce pattern. Code réel (src/Entity/Trait/TimestampableTrait.php) :
#[ORM\HasLifecycleCallbacks]
trait TimestampableTrait
{
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
public private(set) ?\DateTimeImmutable $createdAt = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
public private(set) ?\DateTimeImmutable $updatedAt = null;
#[ORM\PrePersist]
public function onPrePersist(): void
{
$now = new \DateTimeImmutable();
if ($this->createdAt === null) {
$this->createdAt = $now;
}
if ($this->updatedAt === null) {
$this->updatedAt = $now;
}
}
#[ORM\PreUpdate]
public function setUpdatedAtValue(): void
{
$this->updatedAt = new \DateTimeImmutable();
}
}Lecture publique pour les templates, écriture restreinte au trait : l'intention est dans la signature, pas dans une convention orale. Pour Twig, {{ post.createdAt|date }} fonctionne sans getCreatedAt(). Un controller qui tenterait $post->createdAt = new \DateTimeImmutable(); se prend une erreur de visibilité — exactement ce qu'on veut.
Subtilité : private(set) n'empêche pas Foundry ou les fixtures de définir une valeur lors d'un new. Pour les tests qui ont besoin de timestamps custom, on garde une méthode setCreatedAt() explicite à côté — porte dérobée assumée, pas chemin par défaut.
Doctrine ORM 3.4 : ce qu'il a fallu débloquer
Pendant les sept premiers mois de PHP 8.4, Doctrine refusait simplement de fonctionner avec une entité contenant des hooks. La PR #11628 a même introduit un garde-fou explicite avant de pouvoir lever la restriction.
La raison technique : Doctrine utilise la réflexion (ReflectionProperty::setValue() et getValue()) pour hydrater les entités sans déclencher les hooks. Or en PHP 8.4, setValue() passe par le hook par défaut. Charger 100 entités depuis la base, c'est déclencher 100 fois chaque set hook — y compris ceux qui font des trim() ou des strtolower(). Au mieux c'est inutile, au pire ça corrompt des valeurs déjà sanitisées.
PHP a dû exposer deux nouvelles méthodes : ReflectionProperty::setRawValue() et getRawValue(), qui contournent les hooks. Doctrine 3.4.0 les utilise systématiquement pour l'hydratation. Les hooks ne sont donc déclenchés que par le code applicatif, pas par l'ORM.
Conséquence directe : les hooks sont invisibles à l'hydratation. Un set hook qui validerait une cohérence entre deux propriétés ne sera jamais appelé quand Doctrine charge une ligne. Les invariants doivent reposer sur la base ou sur les Validator constraints, pas sur les hooks seuls.
Patterns concrets
Sept patterns que j'utilise, chacun avec sa justification. Tous tirés du repo, pas reconstitués pour l'exemple.
Sanitisation à l'écriture
public string $email = '' {
set(string $value) {
$this->email = strtolower(trim($value));
}
}Toute écriture passe par le filtre. Le code applicatif n'a plus à se rappeler d'appeler strtolower(trim(...)) à chaque assignation. Le get implicite suffit.
Coercition d'enum
public ContentStatusEnum $status = ContentStatusEnum::DRAFT {
set(ContentStatusEnum|string $value) {
$this->status = $value instanceof ContentStatusEnum
? $value
: ContentStatusEnum::from($value);
}
}Les formulaires Symfony renvoient des strings, le code interne manipule des enums. Le hook fait le pont une fois pour toutes.
Propriété calculée sans cache
public string $shard {
get => substr($this->hash, 0, 2);
}Calcul trivial, pas besoin de mémoriser. Pratique pour sharder le stockage des fichiers media par préfixe de hash.
Propriété calculée avec cache
public int $readingTime {
get {
if ($this->readingTimeCache !== null) {
return $this->readingTimeCache;
}
$plain = $this->blocksToPlainText();
if ($plain === '') {
return $this->readingTimeCache = 1;
}
$wordCount = str_word_count($plain);
return $this->readingTimeCache = max(1, (int) ceil($wordCount / 200));
}
}
private ?int $readingTimeCache = null;Le calcul est coûteux (parsing des blocs JSON), donc cache mémoire dans une propriété privée. Le hook reste virtuel, le cache est invisible de l'extérieur.
Garantie d'invariant à la lecture
public array $roles = [] {
get {
$roles = $this->roles;
$roles[] = 'ROLE_USER';
return array_values(array_unique($roles));
}
set(array $value) {
$this->roles = $value;
}
}Tout utilisateur a au moins ROLE_USER, garanti à la lecture. La logique vit avec la donnée — Symfony Security n'a pas besoin de la connaître.
Bridge JSON / array embeddable
public ?string $schemaCustomJson {
get => $this->seo->schemaCustom === [] ? '{}' : (json_encode(
$this->seo->schemaCustom,
\JSON_THROW_ON_ERROR | \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES,
) ?: '{}');
set(?string $value) {
$value = trim($value ?? '');
if ($value === '' || $value === 'null') {
$this->seo->schemaCustom = [];
return;
}
if (!json_validate($value)) {
throw new \InvalidArgumentException('schemaCustomJson invalide : JSON malformé.');
}
$data = json_decode($value, true, 512, \JSON_THROW_ON_ERROR);
if (\is_array($data) && !array_is_list($data)) {
$this->seo->schemaCustom = $data;
}
}
}EasyAdmin attend une string pour son éditeur JSON, l'embeddable stocke un array. Le hook fait l'aller-retour. Le type est nullable parce qu'EasyAdmin peut soumettre null quand l'utilisateur vide le champ. C'est l'usage parfait : exposer un format à l'UI sans contaminer le modèle de domaine.
Label dérivé d'un enum
public string $statusLabel {
get => match ($this->status) {
ContentStatusEnum::DRAFT => 'Brouillon',
ContentStatusEnum::SCHEDULED => 'Planifié',
ContentStatusEnum::ONLINE => 'Publié',
};
}Pas de getStatusLabel() à appeler depuis Twig. {{ post.statusLabel }} suffit. Le match exhaustif est garanti par PHP — ajouter un case à l'enum casse la compilation tant que le hook n'est pas mis à jour.
Interopérabilité Symfony : Forms, Validator, Serializer, EasyAdmin
C'est la question qui décide si on peut migrer ou non. Verdict global : depuis Symfony 7.4 LTS (novembre 2025), tout l'écosystème principal supporte les hooks. Les détails comptent quand même.
PropertyAccessor (utilisé par Forms et Serializer) : invoque automatiquement les hooks sur getValue() et setValue(). Aucune config. Les set hooks de sanitisation sont donc déclenchés à la soumission de formulaire — exactement le comportement souhaitable.
Validator : les contraintes posées sur la propriété (#[Assert\Email], #[Assert\Length]) lisent la valeur via PropertyAccessor, donc via le get hook. Important : la validation s'applique à la valeur retournée par le get hook, pas à la backing value brute. Si le get hook fait strtoupper(), la #[Assert\Length(max: 10)] validera la version majuscule.
Serializer : encore via PropertyAccessor. Les virtual properties sont sérialisées comme des champs normaux à l'export, ce qui est pratique pour exposer du calcul en API sans dupliquer dans un DTO. À la désérialisation, un set hook sert de validation/coercition sans ajouter de Denormalizer.
Forms : aucun cas particulier. Une propriété public private(set) ne peut pas être bindée par un formulaire — ce qui est cohérent avec le sens de la déclaration.
EasyAdmin : compatible. Le seul cas que j'ai dû résoudre concerne les éditeurs custom (TextAreaField pour du JSON brut sur un embeddable array) — résolu par une virtual property bridge comme dans le pattern 7.6.
Limitation documentée : le binding par référence (les rares cas où Symfony Form fait $collection[] = ...) peut bypasser un set hook si on a défini un &get. C'est rare, c'est dans la RFC.
Pièges et anti-patterns à connaître
Cinq erreurs documentées avant de migrer.
ORM\Column sur une propriété virtuelle
Doctrine essaie de la persister → échec au schema diff. Règle : #[ORM\Column] uniquement sur les backed properties.
Set hook qui modifie une autre propriété backed
public float $price = 0.0 {
set {
$this->price = $value;
$this->taxedPrice = $value * 1.20; // <- déclenche le set hook de taxedPrice
}
}Si taxedPrice a aussi un set hook avec validation, on le déclenche en cascade. Souvent volontaire — mais gare aux dépendances circulaires entre deux set hooks qui s'écrivent mutuellement.
Oublier d'écrire dans la backing value en forme bloc
public string $name {
set {
$cleaned = trim($value);
// BUG : on a oublié $this->name = $cleaned;
}
}La forme bloc exige l'assignation explicite. La forme courte (set => trim($value);) écrit automatiquement le retour de l'expression. PHPStan 2.1+ détecte le cas mais ne le bloque pas toujours en niveau standard.
Confondre private(set) et propriété hookée
private(set) toute seule n'est pas un hook. C'est juste une visibilité asymétrique. Le hook est la syntaxe { get => ...; set => ...; }. On peut combiner les deux, mais ce sont deux features distinctes qui interagissent bien.
Quand préférer encore une méthode classique
Les hooks ne remplacent pas tout. Trois cas où je garde une méthode do_something() traditionnelle.
Action métier nommée. $post->publish() raconte une intention, $post->status = ContentStatusEnum::ONLINE est juste une assignation. Pour un changement d'état avec effet de bord (envoi d'événement, vérification de droits), une méthode dédiée reste plus lisible.
Effets de bord asynchrones. Un set hook qui déclencherait un envoi de message dans un bus est piégeux : il s'exécute à toute écriture, y compris celles qu'on ne voit pas (deserialization API, soumission de formulaire). Une méthode explicite est plus honnête.
Logique qui dépend de plusieurs propriétés. Si la cohérence repose sur trois champs (scheduledPublishAt, scheduledUnpublishAt, status), un set hook sur un seul ne peut pas garantir l'invariant. Une méthode schedule(\DateTimeImmutable $from, ?\DateTimeImmutable $to) est plus claire.
Une propriété hookée, c'est une donnée qui sait se défendre toute seule. Une méthode, c'est un geste qu'on veut nommer.
Bilan et bascule vers ORM 4.0
Sur les entités principales du site, le volume de code a baissé sensiblement après migration. Mais le gain de lignes n'est pas l'intérêt. L'intérêt, c'est qu'à l'ouverture d'un fichier, on voit la donnée et ses règles ensemble. Les set hooks et private(set) codent l'intention dans la signature, là où elle est lue, pas à 200 lignes plus bas dans une méthode setX() qu'on espère que tout le monde appellera.
Doctrine ORM 4.0, attendu, exigera PHP 8.4 minimum et basculera entièrement sur les native lazy objects. Le sens du courant est posé : l'encapsulation par hook plutôt que par couple méthode privée + getter.
Ce qui se joue n'est pas qu'une économie de boilerplate. C'est que PHP arrête de singer Java. Pendant vingt ans, on a copié le modèle accesseurs/mutateurs des langages JVM en se disant que c'était la bonne pratique. La bonne pratique tenait au langage qu'on imitait, pas au nôtre. Les hooks ferment ce chapitre. Pour la première fois depuis longtemps, écrire une entité PHP idiomatique ne ressemble plus à du Java avec des dollars.
Plan de migration que j'ai suivi, dans cet ordre :
- PHP 8.4 (puis 8.5), Doctrine ORM 3.4+, Symfony 7.4 LTS minimum.
- Migrer les propriétés simples d'abord (string, int, enum) avec sanitisation triviale.
- Introduire
private(set)sur tous les champs gérés par des Lifecycle Callbacks. - Convertir les
getX()simples en virtual properties. - Reprendre les méthodes
setX()complexes — les transformer si elles ne portaient qu'une transformation, les garder si elles portent une vraie action métier. - Une passe PHPStan max + Rector pour nettoyer les vestiges.
Le mot de la fin
Les Property Hooks règlent un problème de syntaxe en surface. Ce qu'ils règlent en profondeur, c'est un réflexe — celui qui faisait qu'on tapait getX/setX sans se demander si on en avait besoin. Le Maker Bundle nous a aidés pendant dix ans, on a fini par les générer comme on respire. Un défaut machine devenu un défaut humain.
Ce qui m'intéresse à partir d'ici, c'est ce que la feature va déplacer autour. Quand la donnée porte ses propres règles, les Validator constraints commencent à ressembler à un doublon. Les DTOs d'entrée d'API ressemblent à un détour. EasyAdmin pourrait penser ses formulaires en propriétés actives plutôt qu'en champs muets. Rien n'est garanti, rien n'arrivera demain — mais le seuil est franchi, et c'est le genre de seuils qu'on ne franchit que dans un sens.