readonly ne suffit pas à faire un Value Object
La plupart des tutoriels PHP sur le Domain-Driven Design (DDD) finissent par entonner le même refrain : transformez votre Value Object en final readonly class, validez les données dans le constructeur, et le tour est joué. L'immuabilité est atteinte, le contrat est rempli.
C'est un mirage. Et ce mirage s'estompe dès que l'objet est manipulé à l'extérieur de son propre fichier : lorsqu'il doit être comparé dans un test ou dédupliqué dans une collection.
Dans cet article, on va voir ce que readonly apporte vraiment à un Value Object, ce qu'il n'apporte pas, et comment écrire soi-même la partie manquante.
Les trois piliers d'un Value Object
En résumant les définitions posées par Eric Evans et Vaughn Vernon, un véritable Value Object repose sur trois piliers :
- L'absence d'identité propre : deux montants de cinq euros sont le même montant, la question « lequel des deux ? » n'a pas de sens.
- L'immuabilité : cinq euros ne deviennent jamais six euros, on crée une nouvelle valeur.
- L'égalité par la valeur : deux instances qui portent les mêmes données sont égales.
Attention au mot « identité », qui a ici deux sens. En DDD, c'est une identité métier, un identifiant qui permet de suivre un objet dans le temps, comme le BookingId d'une réservation. Un Value Object n'en a pas : on ne distingue jamais deux valeurs identiques. Mais tout objet PHP a aussi une identité technique, son identité en mémoire, celle que teste ===. On ne peut pas la lui retirer, on peut seulement apprendre à l'ignorer.
Le mot-clé readonly de PHP ne coche que la case de l'immuabilité. Il protège l'emplacement de la propriété : elle ne peut pas être réassignée après la construction. Mais il ne retire pas à l'objet son identité en mémoire (pilier 1) et ne dit strictement rien sur l'égalité de deux instances (pilier 3). La suite de l'article montre ce que ça casse, pilier par pilier.
Le cas d'école : l'objet Money
Pour comprendre ce que readonly ne résout pas, un seul Value Object standard suffit : une classe Money immuable, celle utilisée tout au long de cette série, dans la couche domaine d'une application Symfony. Elle stocke le montant en centimes dans un int (jamais dans un float, pour éviter les erreurs d'arrondi) et la devise dans une enum Currency (EUR, USD).
<?php
declare(strict_types=1);
namespace App\Booking\Domain;
final readonly class Money
{
private function __construct(
public int $cents,
public Currency $currency,
) {
if ($cents < 0) {
throw new \InvalidArgumentException('Amount cannot be negative.');
}
}
public static function fromCents(int $cents, Currency $currency): self
{
return new self($cents, $currency);
}
// zero(), add(), percentage(): see companion repo
// equals(): shown further down
}
Un int et un cas d'enum ne peuvent plus changer une fois l'objet construit : le pilier 2 est coché.
Le problème : ce que readonly ne résout pas
Le code ci-dessus est propre. Pourtant, dès qu'on s'en sert, PHP le traite comme n'importe quel objet : avec une identité propre, et sans aucune idée de ce que « égal » veut dire pour lui.
1. === compare l'identité en mémoire, pas la valeur (pilier 1)
Par définition, deux Value Objects contenant les mêmes données sont égaux. Si vous écrivez un test unitaire pour vérifier une égalité, vous allez naturellement utiliser assertSame(), qui s'appuie sur l'opérateur de comparaison stricte ===. Voici ce qu'en pense PHP :
$a = Money::fromCents(5000, Currency::EUR);
$b = Money::fromCents(5000, Currency::EUR);
self::assertTrue($a == $b);
self::assertFalse($a === $b);
Pourquoi ? Parce que pour le moteur de PHP, l'opérateur === appliqué à des objets teste l'identité (« est-ce le même objet ? »), pas la valeur. Bien que vos deux instances représentent exactement la même somme, ce sont deux boîtes distinctes en mémoire, et un assertSame($a, $b) échoue. readonly n'a absolument aucun impact là-dessus : il fige le contenu des boîtes, pas le fait qu'il y en ait deux.
2. array_unique() plante (pilier 3)
Le problème s'aggrave si vous essayez de manipuler vos Value Objects dans des fonctions de tableaux natives. Imaginez que vous vouliez extraire les montants uniques d'une liste de frais :
$amounts = [Money::fromCents(5000, Currency::EUR), Money::fromCents(5000, Currency::EUR)];
$this->expectException(\Error::class);
array_unique($amounts);
Par défaut, array_unique() compare les éléments en les convertissant en chaînes de caractères. Sans méthode magique __toString(), la conversion est impossible et PHP lève une Error : « Object of class Money could not be converted to string ». En production, c'est un plantage partout où le code déduplique des montants. PHP ne sait tout simplement pas comparer deux Money par leur valeur.
3. Les faux amis : == et SORT_REGULAR
Il reste alors deux options : l'opérateur de comparaison lâche == (ou array_unique($amounts, SORT_REGULAR), qui repose dessus), ou des boucles de comparaison écrites à la main. Pour Money, == donne la bonne réponse : il compare les propriétés une à une, et deux montants sont égaux quand leurs centimes et leur devise le sont.
Mais c'est une coïncidence. == compare les propriétés avec les règles lâches de PHP, qui comparent les chaînes numériques comme des nombres. Prenez un Value Object RoomNumber, qui enveloppe un numéro de chambre. Dans un hôtel dont l'annexe numérote ses chambres « 0101 », « 0102 »…, alors que le bâtiment principal a une chambre « 101 », ce sont deux chambres différentes. Pourtant RoomNumber::fromString('0101') == RoomNumber::fromString('101') renvoie true, et array_unique(..., SORT_REGULAR) en supprime une sans prévenir. Aucune normalisation à la construction n'y peut rien, puisque les deux numéros sont valides et distincts.
La solution : écrire l'égalité soi-même
Puisque PHP ne peut pas deviner ce qui rend deux valeurs égales, c'est à la classe de le dire, avec une méthode de comparaison explicite. Pour Money, la règle est la règle métier : même nombre de centimes, même devise.
public function equals(self $other): bool
{
return $this->cents === $other->cents
&& $this->currency === $other->currency;
}
On peut s'étonner de retrouver === ici, alors qu'on vient de l'accuser. Mais il ne compare plus deux objets : il compare un int à un int, et un cas d'enum à un cas d'enum. Or les cas d'enum sont uniques en PHP : Currency::EUR est toujours la même instance. Pour RoomNumber, le même === appliqué à la chaîne distingue bien la chambre « 0101 » de la chambre « 101 ».
Ce n'est pas un contournement : d'autres langages génèrent cette méthode pour vous. Un record Java ou C#, ou une data class Kotlin, fournit l'égalité par valeur à partir de ses champs, avec des champs immuables (en Kotlin, à condition de les déclarer en val). PHP fournit l'immuabilité avec readonly et vous laisse écrire l'égalité.
Dans les tests, le problème se règle en remplaçant assertSame($a, $b) par assertTrue($a->equals($b)) :
#[Test]
public function equalsReturnsTrueForTheSameAmountAndCurrency(): void
{
$a = Money::fromCents(5000, Currency::EUR);
$b = Money::fromCents(5000, Currency::EUR);
self::assertTrue($a->equals($b));
}
Pour les collections, SORT_REGULAR suffit tant que == et equals() sont d'accord, comme pour Money. Pour un cas comme RoomNumber, il faut une petite boucle qui déduplique en s'appuyant sur equals().
Points de vigilance
- Rien ne vérifie
equals()à votre place. Ajoutez une propriété àMoney, oubliez de mettreequals()à jour, et tous les tests existants passent encore. Un test par propriété qui définit la valeur est le seul filet de sécurité. - Jamais
assertSame()sur un Value Object. Dans les tests, on compare toujours avecequals().
readonly est superficiel. Il empêche de réassigner une propriété, pas de modifier l'objet vers lequel elle pointe. Money ne contient qu'un int et une enum, il est donc entièrement immuable. Une classe readonly qui expose un \DateTime ne l'est pas :
final readonly class StayWithMutableCheckIn
{
public function __construct(public \DateTime $checkIn)
{
}
}
$stay = new StayWithMutableCheckIn(new \DateTime('2027-01-10'));
$stay->checkIn->modify('+1 day');
self::assertSame('2027-01-11', $stay->checkIn->format('Y-m-d'));
La propriété checkIn n'a jamais été réassignée, et pourtant la date d'arrivée a changé. Avec un DateTimeImmutable, modify() aurait renvoyé un nouvel objet, et la date stockée dans $stay serait restée intacte.
Et demain ?
readonly est un excellent outil pour verrouiller l'écriture, mais il ne saura jamais apprendre à PHP ce qui rend deux valeurs réellement égales. Deux propositions (RFC), écrites par Rob Landers en 2024 (Records en juillet, Data Class en novembre), voudraient régler le problème directement dans le moteur :
- PHP RFC: Records : un nouveau type
record, immuable, où deux records qui portent les mêmes valeurs sont égaux avec===. - PHP RFC: Data Class : des classes comparées par valeur avec
===, modifiables, mais avec une copie à l'écriture (copy-on-write), comme les tableaux PHP.
Aucune des deux n'a été soumise au vote (vérifié sur wiki.php.net le 29 septembre 2026) : elles ne changent rien au code qu'on écrit aujourd'hui.
En résumé
- Pilier 1, l'absence d'identité : un objet PHP a toujours une identité en mémoire. On la neutralise en comparant avec
equals(), jamais avec===. - Pilier 2, l'immuabilité :
readonlys'en charge, tant que les propriétés sont elles-mêmes immuables. - Pilier 3, l'égalité par la valeur : c'est à vous de l'écrire, dans
equals(), et de la tester.
Utilisez readonly pour protéger vos données contre les modifications sauvages, et continuez à écrire rigoureusement vos méthodes equals().
Si votre propre modèle de domaine s'appuie sur === ou assertSame() pour comparer des valeurs, c'est l'une des premières choses que je vérifie lors d'un audit de code, avant que les données de production ne le découvrent à votre place.