Aller au contenu principal
Retour au blog

PHP 8.4 Property Hooks : la fin du boilerplate getter/setter

Flavien Métivier4 juin 20248 min

Depuis PHP 5, le rituel est immuable : pour chaque propriété d'entité, tu écris un getName(), un setName(), tu valides dedans, tu copies-colles, tu oublies un cas, et tu te demandes pourquoi tu fais encore ça en 2024. Le RFC property hooks, co-rédigé par Larry Garfield et Ilija Tovilo, a été accepté par l'internals PHP au printemps 2024 et intégré dans PHP 8.4, dont l'Alpha 1 est publiée le 6 juin 2024. La sortie stable est prévue le 21 novembre 2024. Voici ce que ça change concrètement — et ce que ça ne change pas encore — dans tes entités Doctrine et tes Value Objects Symfony, avant même que la feature soit disponible en production.

Un RFC inspiré de Kotlin, C# et Swift

Le RFC ne part pas de rien. Les property accessors de C# (depuis C# 3.0 en 2007), les computed properties de Swift et le système get/set de Kotlin ont tous résolu le même problème : une propriété n'est pas qu'un champ de données. Elle encapsule des règles, des transformations, des représentations différentes à la lecture et à l'écriture. PHP avait jusqu'ici deux réponses : les méthodes getter/setter (verbeuses, découplées de la propriété qu'elles servent) ou les __get/__set magiques (opaques, impossibles à typer statiquement, invisibles aux IDE). Les property hooks proposent une troisième voie : définir la logique d'accès directement sur la propriété, avec un typage fort et une analyse statique possible. Le vote internals s'est conclu au printemps 2024 avec une large majorité pour. PHP 8.4 embarque également un second RFC complémentaire — asymmetric visibility (public private(set)) — qui couvre un sous-ensemble du même problème. Les deux coexistent sans conflit et se complètent selon le cas d'usage.

La syntaxe get/set : décortiquons le RFC

Les hooks se déclarent dans un bloc {} qui suit immédiatement la déclaration de type de la propriété. Deux hooks sont disponibles : get (appelé à chaque lecture) et set (appelé à chaque écriture). Chacun supporte une forme courte avec => pour les expressions simples, ou une forme longue avec un bloc et un paramètre typé explicite. Dans le hook set, le paramètre s'appelle $value par défaut ou peut être renommé avec une signature explicite. Dans le hook get, l'accès à $this->propName depuis l'intérieur du hook pointe vers le backing field — la valeur stockée — et non vers le hook lui-même : pas de récursion infinie possible.

<?php

declare(strict_types=1);

class Product
{
    /**
     * Propriété backed : stockée, validée et normalisée par les hooks.
     * $this->name dans les hooks pointe vers le backing field, sans récursion.
     */
    public string $name {
        // Forme courte : get retourne une expression
        get => ucwords(strtolower($this->name));

        // Forme longue : bloc avec paramètre typé explicite
        set(string $value) {
            $value = trim($value);
            if (strlen($value) < 2) {
                throw new \InvalidArgumentException(
                    sprintf('Le nom "%s" est trop court (minimum 2 caractères).', $value)
                );
            }
            $this->name = $value; // affectation au backing field
        }
    }

    /**
     * Propriété backed avec hook set seul.
     * La lecture retourne la valeur brute du backing field (pas de get hook).
     */
    public float $price {
        set(float $value) {
            if ($value < 0.0) {
                throw new \DomainException('Le prix ne peut pas être négatif.');
            }
            $this->price = round($value, 2);
        }
    }
}

$p       = new Product();
$p->name = '  widget pro  '; // set hook → normalise et stocke "widget pro"
echo $p->name;               // get hook → affiche "Widget Pro"

$p->price = 19.999;          // set hook → stocke 20.0 (arrondi 2 décimales)
$p->price = -1.0;            // \DomainException

Backed vs virtuelle : la distinction clé

Une propriété est backed si son hook set assigne à $this->propName — la valeur est stockée en mémoire et peut être persistée par un ORM. Elle est virtuelle si son hook get ne retourne jamais $this->propName directement et qu'il n'y a pas de hook set qui stocke quoi que ce soit. Les propriétés virtuelles n'ont pas de colonne Doctrine possible, pas d'espace mémoire alloué : elles sont calculées à la volée à chaque lecture. Cette distinction est fondamentale pour l'ORM et pour la sérialisation.

<?php

declare(strict_types=1);

class Order
{
    /**
     * Propriété virtuelle : aucun backing field, calculée à chaque lecture.
     * Doctrine NE PEUT PAS mapper cette propriété — pas de #[ORM\Column] possible.
     */
    public float $totalWithTax {
        get => round($this->subtotal * (1 + $this->vatRate), 2);
    }

    /**
     * Propriété virtuelle en lecture seule : libellé formaté.
     * Accède à $this->totalWithTax qui lui-même appelle son get hook.
     */
    public string $summary {
        get => sprintf(
            'Commande #%d — %.2f %s TTC',
            $this->id,
            $this->totalWithTax,
            'EUR'
        );
    }

    public function __construct(
        public readonly int   $id,
        public readonly float $subtotal,
        public readonly float $vatRate = 0.20,
    ) {}
}

$order = new Order(id: 42, subtotal: 100.0);
echo $order->totalWithTax; // 120.0
echo $order->summary;      // "Commande #42 — 120.00 EUR TTC"

// $order->totalWithTax = 999; // Erreur : propriété virtuelle sans hook set

Avant/après : ce que le boilerplate getter/setter devient

Pour mesurer l'impact réel, voici une entité classique PHP 8.3 avec validation dans les setters, réécrite avec les property hooks PHP 8.4. Trois propriétés, six méthodes aujourd'hui — et la logique de validation éparpillée loin de la déclaration des données.

<?php

declare(strict_types=1);

// PHP 8.3 — pattern getter/setter classique
class Article
{
    private string             $title       = '';
    private string             $slug        = '';
    private \DateTimeImmutable $publishedAt;

    public function getTitle(): string { return $this->title; }

    public function setTitle(string $title): static
    {
        $title = trim($title);
        if (strlen($title) < 5) {
            throw new \InvalidArgumentException('Le titre doit faire au moins 5 caractères.');
        }
        $this->title = $title;
        return $this;
    }

    public function getSlug(): string { return $this->slug; }

    public function setSlug(string $slug): static
    {
        $this->slug = strtolower(preg_replace('/[^a-z0-9]+/i', '-', trim($slug)));
        return $this;
    }

    public function getPublishedAt(): \DateTimeImmutable { return $this->publishedAt; }

    public function setPublishedAt(\DateTimeImmutable $date): static
    {
        if ($date > new \DateTimeImmutable()) {
            throw new \DomainException('La date de publication ne peut pas être dans le futur.');
        }
        $this->publishedAt = $date;
        return $this;
    }
}
// 3 propriétés → 6 méthodes → logique éparpillée

Besoin d'un expert Symfony ?

Réserver un appel
<?php

declare(strict_types=1);

// PHP 8.4 — property hooks : logique colocalisée avec les propriétés
class Article
{
    public string $title {
        set(string $value) {
            $value = trim($value);
            if (strlen($value) < 5) {
                throw new \InvalidArgumentException('Le titre doit faire au moins 5 caractères.');
            }
            $this->title = $value;
        }
    }

    public string $slug {
        get => strtolower(preg_replace('/[^a-z0-9-]+/', '-', $this->slug));
        set(string $value) {
            $this->slug = trim($value);
        }
    }

    public \DateTimeImmutable $publishedAt {
        set(\DateTimeImmutable $value) {
            if ($value > new \DateTimeImmutable()) {
                throw new \DomainException('La date de publication ne peut pas être dans le futur.');
            }
            $this->publishedAt = $value;
        }
    }
}

$article              = new Article();
$article->title       = '  Mon super article  '; // normalise et valide
$article->slug        = 'Mon Super Article';      // stocké tel quel
$article->publishedAt = new \DateTimeImmutable(); // date du jour : OK

echo $article->slug; // "mon-super-article" (get hook appliqué)

On passe de six méthodes publiques à zéro méthode séparée. La règle métier réside désormais là où la donnée est déclarée, pas à 30 lignes d'écart dans le fichier. En code review, tu vois la contrainte en même temps que le type. Pour un nouveau développeur sur le projet, la compréhension du domaine est immédiate : plus besoin de chercher si un setSlug() transforme silencieusement la valeur quelque part.

Entités Doctrine et PHP 8.4 : ce qui va (et ce qui mérite surveillance)

Doctrine ORM 3.x, compatible Symfony 7.1, utilise la réflexion PHP pour hydrater les entités. En PHP 8.4, ReflectionProperty::getValue() et setValue() sont hook-aware par conception du RFC : ils passent par les hooks si ceux-ci sont définis. En théorie, une entité avec propriétés backed et hooks devrait être hydratée correctement sans modification de Doctrine. En pratique, trois zones méritent une surveillance active pendant la période Alpha/RC : les proxies Doctrine (qui héritent de tes entités et pourraient interférer avec la résolution des hooks), les listeners de cycle de vie (PreUpdate, PostLoad) qui accèdent aux propriétés hors contexte normal, et les embeddables. Règle absolue : les propriétés virtuelles ne sont jamais mappables — ne leur ajoute jamais #[ORM\Column], Doctrine ne peut pas lire un backing value qui n'existe pas.

<?php

declare(strict_types=1);

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
#[ORM\Table(name: 'products')]
class Product
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    public readonly int $id;

    // Propriété backed → #[ORM\Column] légal
    // ReflectionProperty::setValue() passe par le hook set à l'hydratation
    #[ORM\Column(length: 255)]
    public string $name {
        set(string $value) {
            $value = trim($value);
            if ('' === $value) {
                throw new \InvalidArgumentException('Le nom du produit ne peut pas être vide.');
            }
            $this->name = $value;
        }
    }

    // Hook get + set sur propriété backed — la colonne stocke la valeur brute
    // Le hook get arrondit systématiquement à 2 décimales à la lecture
    #[ORM\Column(type: 'decimal', precision: 10, scale: 2, options: ['default' => '0.00'])]
    public float $price {
        get => round($this->price, 2);
        set(float $value) {
            if ($value < 0.0) {
                throw new \DomainException('Le prix ne peut pas être négatif.');
            }
            $this->price = $value;
        }
    }

    // Propriété VIRTUELLE — pas de #[ORM\Column]
    // Aucun backing field, non persistée, calculée à la volée
    public string $label {
        get => sprintf('%s (%.2f €)', $this->name, $this->price);
    }
}

Support outillage : où en est-on en juin 2024 ?

PHP 8.4 est en Alpha 1 au moment où cet article paraît — le support outillage suit, imparfaitement, et c'est normal à ce stade du cycle de release. PHPStan et Psalm ont des issues ouvertes pour reconnaître la syntaxe des hooks ; aucune version stable ne les analyse encore correctement. PhpStorm intègre le suivi des features PHP 8.4 dans ses EAP (Early Access Program) : l'auto-complétion et l'analyse locale des property hooks devraient être disponibles avant la release stable de novembre. Rector ne propose pas encore de règles de migration automatique getters → hooks, mais le chantier est tracé côté mainteneurs. En production, PHP 8.4 ne sera pas disponible avant novembre 2024 : cet article te donne six mois pour anticiper les patterns et tester sur une branche de veille sans aucune urgence.

Ce que les property hooks ne changent pas encore

Deux attentes légitimes ne sont pas couvertes par ce RFC. D'abord, les interfaces ne peuvent pas déclarer de hooks sur leurs propriétés dans PHP 8.4 : tu ne peux pas encore définir un contrat interface HasName { public string $name { get; } }. Ce point est ouvert pour une version future. Ensuite, les propriétés statiques ne supportent pas les hooks — la mécanique de backing field n'est pas compatible avec le storage statique en l'état. Enfin, les property hooks ne remplacent pas tous les cas d'usage des accesseurs : si une méthode setPrice() doit retourner $this pour du method chaining ou accepter plusieurs paramètres, un setter classique reste la solution. Les hooks n'ont pas de valeur de retour et ne reçoivent qu'un seul argument.

À retenir

  • Les property hooks (PHP 8.4) permettent de définir la logique get/set directement dans la déclaration de propriété, sans méthodes séparées.
  • Une propriété backed stocke une valeur (ORM-compatible) ; une propriété virtuelle est calculée à la volée — ne lui ajoute jamais #[ORM\Column].
  • La réflexion PHP est hook-aware : Doctrine peut hydrater des entités avec hooks, mais surveille les proxies et les listeners pendant la période Alpha/RC.
  • Le support outillage (PHPStan, Psalm, Rector) est en cours en juin 2024 : préfère une branche de veille plutôt qu'une adoption immédiate en production.
  • Les interfaces et les propriétés statiques ne supportent pas les hooks dans PHP 8.4 — garde tes getters dans ces cas spécifiques.
  • Release stable PHP 8.4 attendue le 21 novembre 2024 : six mois pour anticiper sans pression.

Cet article vous a plu ? Partagez-le !

Besoin d'un expert Symfony ?

20 ans d'expérience sur l'écosystème PHP/Symfony.