Readonly Doesn't Make a Value Object
Most PHP tutorials on Domain-Driven Design (DDD) end on the same note: make your Value
Object a final readonly class, validate the data in the constructor, and
you're done. Immutability is achieved, the contract is fulfilled.
It's a mirage, and it fades the moment the object is used outside its own file: when it has to be compared in a test, or deduplicated in a collection.
This article covers what readonly actually gives a Value Object, what it
doesn't, and how to write the missing part yourself.
The three pillars of a Value Object
Summing up the definitions laid down by Eric Evans and Vaughn Vernon, a real Value Object rests on three pillars:
- No identity of its own: two five-euro amounts are the same amount, and "which one?" is a meaningless question.
- Immutability: five euros never turn into six euros, you create a new value instead.
- Equality by value: two instances holding the same data are equal.
Watch out for the word "identity", which has two meanings here. In DDD, it's a
business identity: an identifier that lets you follow an object over time, like a
booking's BookingId. A Value Object has none: two identical values are
never told apart. But every PHP object also has a technical identity, its identity in
memory, the one === tests. You can't take it away, you can only learn to
ignore it.
PHP's readonly keyword only ticks the immutability box. It protects the
property slot: the property can't be reassigned after construction. But it doesn't
take the object's identity in memory away (pillar 1), and it says nothing at all about
whether two instances are equal (pillar 3). The rest of this article shows what that breaks,
pillar by pillar.
The textbook case: Money
One ordinary Value Object is enough to see what readonly leaves unsolved:
an immutable Money class, the one used throughout this series, in the
domain layer of a Symfony application. It stores the
amount in cents in an int (never a float, to avoid rounding
errors) and the currency in a Currency enum (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
}
An int and an enum case can't change once the object is built: pillar 2
is ticked.
The problem: what readonly doesn't solve
The code above is clean. Yet as soon as you use it, PHP treats it like any other object: with an identity of its own, and no idea what "equal" means for it.
1. === compares identity in memory, not value (pillar 1)
By definition, two Value Objects holding the same data are equal. Write a unit test to
check that, and you'll naturally reach for assertSame(), which relies on
the strict comparison operator ===. Here's what PHP makes of it:
$a = Money::fromCents(5000, Currency::EUR);
$b = Money::fromCents(5000, Currency::EUR);
self::assertTrue($a == $b);
self::assertFalse($a === $b);
That's because, for PHP's engine, === applied to objects tests
identity ("is this the same object?"), not value. Your two instances
represent exactly the same amount, but they're two separate boxes in memory, so
assertSame($a, $b) fails. readonly has no effect on this at
all: it freezes what's inside the boxes, not the fact that there are two of them.
2. array_unique() crashes (pillar 3)
It gets worse with PHP's native array functions. Say you want the unique amounts from a list of charges:
$amounts = [Money::fromCents(5000, Currency::EUR), Money::fromCents(5000, Currency::EUR)];
$this->expectException(\Error::class);
array_unique($amounts);
By default, array_unique() compares elements by converting them to
strings. Without a __toString() method, the conversion is impossible and
PHP throws an Error: "Object of class Money could not be converted to
string". In production, that's a crash wherever the code deduplicates amounts. PHP
simply doesn't know how to compare two Money objects by value.
3. False friends: == and SORT_REGULAR
That leaves two options: the loose comparison operator == (or
array_unique($amounts, SORT_REGULAR), which relies on it), or comparison
loops of your own. For Money, == gives the right answer: it
compares properties one by one, and two amounts are equal when their cents and their
currency are.
But that's a coincidence. == compares properties with PHP's loose rules,
and those rules compare numeric strings as numbers. Take a RoomNumber
Value Object, which wraps a room number. In a hotel whose annex
numbers its rooms "0101", "0102" and so on, while the main building has a room "101",
those are two different rooms. Yet
RoomNumber::fromString('0101') == RoomNumber::fromString('101') returns
true, and array_unique(..., SORT_REGULAR) silently drops one
of them. No normalization in the constructor can help, since both numbers are valid
and distinct.
The solution: write equality yourself
Since PHP can't guess what makes two values equal, the class has to say it, with an
explicit comparison method. For Money, the rule is the business rule: same
number of cents, same currency.
public function equals(self $other): bool
{
return $this->cents === $other->cents
&& $this->currency === $other->currency;
}
It may seem odd to find === here, right after blaming it. But it no
longer compares two objects: it compares an int with an int,
and an enum case with an enum case. Enum cases are unique in PHP:
Currency::EUR is always the same instance. For RoomNumber,
the same === applied to the string correctly tells room "0101" from room
"101".
This isn't a workaround: other languages generate this method for you. A Java or C#
record, or a Kotlin data class, provides equality by value
from its fields, with immutable fields (in Kotlin, as long as they're declared with
val). PHP gives you immutability through readonly and leaves
equality for you to write.
In tests, the fix is to replace assertSame($a, $b) with
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));
}
For collections, SORT_REGULAR is enough as long as == and
equals() agree, as they do for Money. For a case like
RoomNumber, you need a small deduplication loop built on
equals().
Things to watch
- Nothing checks
equals()for you. Add a property toMoney, forget to updateequals(), and every existing test still passes. A test per property that defines the value is the only safety net. - Never
assertSame()on a Value Object. In tests, always compare withequals().
readonly is shallow. It stops a property from being
reassigned, not the object it points to from changing. Money only holds
an int and an enum, so it's fully immutable. A readonly class
exposing a \DateTime isn't:
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'));
The checkIn property was never reassigned, yet the check-in date changed.
With a DateTimeImmutable, modify() would have returned a new
object, and the date held by $stay would have stayed untouched.
What about tomorrow?
readonly is an excellent tool for locking writes, but it will never teach
PHP what makes two values truly equal. Two proposals (RFCs), written by Rob Landers in
2024 (Records in July, Data Class in November), aim to fix this in the engine itself:
- PHP RFC: Records:
a new
recordtype, immutable, where two records holding the same values are equal with===. - PHP RFC: Data Class:
classes compared by value with
===, which can be modified but copy themselves on write (copy-on-write, like PHP arrays).
Neither has been put to a vote (checked on wiki.php.net on September 29, 2026), so neither changes the code you write today.
In short
- Pillar 1, no identity: PHP objects always have an identity in
memory. You neutralize it by comparing with
equals(), never with===. - Pillar 2, immutability:
readonlytakes care of it, as long as the properties are immutable themselves. - Pillar 3, equality by value: it's yours to write, in
equals(), and to test.
Use readonly to protect your data from stray modifications, and keep
writing your equals() methods carefully.
If your own domain model relies on === or assertSame() to
compare values, that's one of the first things I check in a
code audit, before production data finds it for you.