PHP 8.4 Property Hooks : quand Doctrine 3.4 révolutionne vos Getters/Setters
Découvrez les Property Hooks PHP 8.4 avec Doctrine 3.4. Fini les getters/setters verbeux !
Le contexte : comment PHP devient moderne
Il était une entité Doctrine avec ces 200 lignes de getters/setters pour 10 propriétés. L’époque des cris et des pleurs lors d’une refacto est désormais révolue avec la sortie de PHP 8.4 et l’introduction de ces fameux Property Hooks avec Doctrine 3.4.
Qu'est-ce que les Property Hooks ?
Les Property Hooks, c'est la réponse de PHP à tous ceux qui jalousaient C# et ses properties depuis 2002. Oui, on a mis 22 ans à rattraper le coup, mais il vaut mieux tard que jamais.
En gros, au lieu d'écrire ça :
class User {
private string $name;
public function getName(): string
{
return $this->name;
}
public function setName(string $name): void
{
$this->name = $name;
}
}
Vous pouvez maintenant faire ça :
class User {
public string $name;
}
Et là vous me dites « Tu bluffes Martoni, ça existe depuis PHP 4 ». C’est là qu’entrent que les hooks entrent en jeu :
class User {
public string $name {
get => strtoupper($this->name);
set => $this->name = trim($value);
}
}
On peut intégrer de la logique custom, syntaxe élégante et vos yeux ne saignent plus avec les 500 lignes des getters et setters : tout est au même endroit.
Doctrine 3.4
Le 28 juin 2025, l'équipe Doctrine fait l’annonce : Doctrine ORM 3.4 supporte officiellement les Property Hooks.
Ce Qui Change Concrètement
Avant Doctrine 3.4, vos entités ressemblaient à ça :
#[ORM\Entity]
class Post {
#[ORM\Id]
#[ORM\Column(type: 'uuid')]
private ?Uuid $id = null;
#[ORM\Column(length: 255)]
private string $title = '';
#[ORM\Column(type: Types::TEXT)]
private ?string $content = null;
public function getId(): ?Uuid
{
return $this->id;
}
public function getTitle(): string
{
return $this->title;
}
public function setTitle(string $title): static
{
$this->title = $title;
return $this;
}
public function getContent(): ?string
{
return $this->content;
}
public function setContent(?string $content): static
{
$this->content = $content;
return $this;
}
}
Avec Doctrine 3.4 et PHP 8.4, voici la même entité :
#[ORM\Entity]
class Post {
#[ORM\Id]
#[ORM\Column(type: 'uuid')]
public ?Uuid $id = null;
#[ORM\Column(length: 255)]
public string $title = '';
#[ORM\Column(type: Types::TEXT)]
public ?string $content = null;
}
60% de code en moins. Plus de getters/setters dans le trait = moins de conflits potentiels avec les classes qui l'utilisent. Win-win.
La refactorisation du code
Mise à jour des dépendances
D'abord, le classique :
{
"require": {
"doctrine/orm": "^3.5", // La 3.5 était sortie donc c'est bonus
"doctrine/doctrine-bundle": "^2.15"
}
}
Et ensuite :
composer update doctrine/orm doctrine/doctrine-bundle
L’analyse du code existant
J'ai commencé par scanner toutes mes entités. Le bonheur [ non ].
Voici mon approche systématique :
- Identifier les propriétés simples : Celles sans logique custom dans les getters/setters
- Repérer les cas complexes : Lazy loading, validation custom, transformations
- Planifier la migration par lots : Commencer par les entités les plus simples
La migration proprement dite
Les Entités Simples
Prenons l'exemple de mon entité Category
:
Avant :
class Category extends AbstractContent
{
#[ORM\OneToMany(targetEntity: Post::class, mappedBy: 'category')]
private Collection $posts;
public function __construct()
{
parent::__construct();
$this->posts = new ArrayCollection();
}
public function getPosts(): Collection
{
return $this->posts;
}
public function addPost(Post $post): static
{
if (!$this->posts->contains($post)) {
$this->posts->add($post);
$post->setCategory($this);
}
return $this;
}
public function removePost(Post $post): static
{
if ($this->posts->removeElement($post) && $post->getCategory() === $this) {
$post->setCategory(null);
}
return $this;
}
}
Après :
class Category extends AbstractContent
{
#[ORM\OneToMany(targetEntity: Post::class, mappedBy: 'category')]
public Collection $posts;
}
C'est pas plus compliqué que ça !
Les entités complexes
L'entité AbstractContent
qui était assez verbeuse :
abstract class AbstractContent implements \Stringable
{
#[ORM\Column(type: 'uuid', unique: true)]
#[ORM\Id]
public ?Uuid $id;
#[ORM\Column(length: 255)]
public string $title = '';
#[ORM\Column(length: 255)]
public string $slug = '';
#[ORM\Column(type: Types::TEXT, nullable: true)]
public ?string $content = null;
#[ORM\Column(nullable: true, enumType: ColorEnum::class)]
public ?ColorEnum $color = ColorEnum::BLUE;
#[ORM\Column(type: Types::STRING, enumType: ContentStatusEnum::class)]
public ContentStatusEnum $status = ContentStatusEnum::DRAFT;
#[ORM\Embedded(class: Seo::class)]
public Seo $seo;
public function __construct()
{
$this->seo = new Seo();
$this->id = Uuid::v4();
}
}
Et c’est tout !
Le cas des embeddables
#[ORM\Embeddable]
class Seo
{
#[ORM\Column(length: 255, nullable: true)]
public ?string $title = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
public ?string $description = null;
#[ORM\Column(nullable: true)]
public ?array $keywords = null;
#[ORM\Column(length: 255, nullable: true)]
public ?string $robots = 'index, follow';
}
Plus de getters/setters ici non plus. L'accès se fait maintenant via : $post->seo->title
au lieu de $post->getSeo()->getTitle()
. C'est beau, on dirait presque du code moderne.
Les relations NtoN
class Post extends AbstractContent
{
#[ORM\JoinColumn(name: 'category_id', referencedColumnName: 'id')]
#[ORM\ManyToOne(inversedBy: 'posts')]
public ?Category $category = null {
set(?Category $value) {
$value?->posts->add($this);
$this->category = $value;
}
}
}
Plus de prise de tête avec les ad()
et remove()
.
Adapter le code consommateur
C'est là que ça devient sportif. Tous les endroits où vous appeliez des getters/setters doivent être mis à jour. Mon approche :
- Recherche globale :
->get
et->set
dans tout le projet - Remplacement systématique : Avec l'aide de PHPStorm et ses capacités de refactoring
- Tests, tests, tests : Lancer la suite de tests après chaque lot de modifications
Exemples concrets : Avant :
$category = $this->categoryService->getCategoryBySlug($slug);
$posts = $category->getPosts();
$title = $category->getTitle();
Après :
$category = $this->categoryService->getCategoryBySlug($slug);
$posts = $category->posts;
$title = $category->title;
Le mot de la fin
Bien entendu, après avoir testé ce changement de patterns, je me suis plus ou moins casser les dents sur certains points :
Les points positifs ✅
- Réduction drastique du boilerplate : -60% de lignes de code
- Lisibilité améliorée : Le code est plus naturel
- Performances légèrement meilleures : Pas révolutionnaire, mais appréciable
- Maintenance simplifiée : Moins de code = moins de bugs potentiels
- Modernité : PHP rattrape (enfin) son retard
Les Points Négatifs ❌
- Courbe d'apprentissage : Se déshabituer des patterns appris
- Compatibilité : Certains outils/bundles ne sont pas prêts
- Debugging plus complexe : Les Hooks peuvent masquer de la logique
- Documentation : On est encore en territoire pionnier
Si vous démarrez un nouveau projet en PHP 8.4+, essayer les Property Hooks, c’est les adopter.
Si vous avez un projet existant, évaluez le ROI. Pour un petit projet, la migration peut se faire en un week-end. Pour un monstre enterprise avec 500 entités, prévoyez une stratégie de migration progressive.
Dans tous les cas, les Property Hooks ne sont pas qu'un gadget syntaxique. C'est une évolution fondamentale de la façon dont on écrit du PHP orienté objet.
PS : Si vous trouvez des bugs dans votre migration, rappelez-vous : ce ne sont pas des bugs, ce sont des "opportunités d'apprentissage non documentées". Et si ça casse vraiment tout, git revert est votre ami.
PPS : Pour les puristes qui vont me dire que les getters/setters c'est de l'encapsulation et que c'est sacré : oui, mais non. L'encapsulation, c'est contrôler l'accès et la modification. Les hooks font exactement ça, juste avec 80% de code en moins. Deal with it.