PHP · Domain-Driven Design

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:

  1. No identity of its own: two five-euro amounts are the same amount, and "which one?" is a meaningless question.
  2. Immutability: five euros never turn into six euros, you create a new value instead.
  3. 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).

Money.php
<?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:

MoneyTest.php
$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:

MoneyTest.php
$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.

Money.php
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)):

MoneyTest.php
#[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

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:

StayWithMutableCheckIn.php
final readonly class StayWithMutableCheckIn
{
    public function __construct(public \DateTime $checkIn)
    {
    }
}
ReadonlyIsShallowTest.php
$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:

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

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.

💬 Working on something similar?

Comments

← Back to Engineering Notes