DEV Community

Michel MAIER
Michel MAIER

Posted on Originally published at php-freelance.com

The Readonly Trap — PHP Value Objects & DDD Aggregates

readonly tells PHP a property can't be reassigned. It says nothing about whether two values are equal, or whether what's inside stays immutable. A full booking aggregate shows exactly where that gap bites.

Every PHP DDD tutorial reaches the same beat: make your Value Object a final readonly class, validate in the constructor, done, immutability achieved. That's not wrong. It's incomplete in a way that only shows up once the object leaves the file it was written in: compared in a test, deduplicated in a collection, round-tripped through Doctrine.

readonly protects exactly one thing: the property slot can't be reassigned after construction. It says nothing about whether two Money instances holding the same amount are equal, and nothing about whether a value nested inside stays immutable once you hand it to someone else. Both are separate contracts. PHP doesn't write either one for you.

What follows is a full aggregate, Entities and Value Objects doing their actual jobs, then the four places where trusting readonly alone quietly breaks something.

A booking, modeled properly

A small hotel booking domain, kept just complex enough to need every piece: an aggregate root (Booking), a child Entity with its own identity and a lifecycle (Charge), and four Value Objects doing the actual work. One real business rule is baked in: cancelling less than 48 hours before check-in adds a penalty charge, and only the aggregate root is allowed to make that happen.

DateRange only needs to answer two questions: how long, and does it overlap another.

<?php

declare(strict_types=1);

namespace App\Booking\Domain;

final readonly class DateRange
{
    private function __construct(
        public \DateTimeImmutable $checkIn,
        public \DateTimeImmutable $checkOut,
    ) {
        if ($checkOut <= $checkIn) {
            throw new \InvalidArgumentException('Check-out must be after check-in.');
        }
    }

    public static function of(\DateTimeImmutable $checkIn, \DateTimeImmutable $checkOut): self
    {
        return new self($checkIn, $checkOut);
    }

    public function nights(): int
    {
        return $this->checkIn->diff($this->checkOut)->days;
    }

    public function overlaps(self $other): bool
    {
        return $this->checkIn < $other->checkOut
            && $other->checkIn < $this->checkOut;
    }

    public function equals(self $other): bool
    {
        return $this->checkIn == $other->checkIn
            && $this->checkOut == $other->checkOut;
    }
}
Enter fullscreen mode Exit fullscreen mode

Every Value Object here keeps its constructor private and exposes a named static factory instead, even one this simple. The payoff isn't visible yet: nothing outside this file can ever call new DateRange(...) directly, so a rule added to of() later applies everywhere, with no call site left behind.

Currency needs exactly one guarantee: only real, known codes exist.

<?php

namespace App\Booking\Domain;

enum Currency: string
{
    case EUR = 'EUR';
    case USD = 'USD';
}
Enter fullscreen mode Exit fullscreen mode

Money is the one every tutorial gets right on the surface, which is exactly why it's the best example a few sections down.

<?php

declare(strict_types=1);

namespace App\Booking\Domain;

final readonly class Money
{
    private function __construct(
        public int $cents,
        public Currency $currency,
    ) {
    }

    public static function fromCents(int $cents, Currency $currency = Currency::EUR): self
    {
        return new self($cents, $currency);
    }

    public function add(self $other): self
    {
        $this->assertSameCurrency($other);

        return new self($this->cents + $other->cents, $this->currency);
    }

    public function percentage(int $percent): self
    {
        return new self(
            (int) round($this->cents * $percent / 100),
            $this->currency,
        );
    }

    public function equals(self $other): bool
    {
        return $this->cents === $other->cents
            && $this->currency === $other->currency;
    }

    private function assertSameCurrency(self $other): void
    {
        if ($this->currency !== $other->currency) {
            throw new \InvalidArgumentException('Currency mismatch.');
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice equals() uses === on cents and currency, and that's correct on both: an int and an enum case, PHP's own singletons, compare correctly with ===. A bare string $currency would have accepted 'EURO' or a typo and said nothing. Same mistake as trusting readonly alone, one type down. The bug this article is actually about shows up next, when === gets used on two Money objects instead.

ChargeId and BookingId are the same shape: a wrapped identifier, nothing more.

<?php

declare(strict_types=1);

namespace App\Booking\Domain;

use Symfony\Component\Uid\Uuid;

final readonly class ChargeId
{
    private function __construct(public string $value)
    {
    }

    public static function generate(): self
    {
        return new self(Uuid::v7()->toRfc4122());
    }

    public static function fromString(string $value): self
    {
        return new self($value);
    }

    public function equals(self $other): bool
    {
        return $this->value === $other->value;
    }
}
Enter fullscreen mode Exit fullscreen mode

BookingId is copy-pasted from this with the name changed. Not shown, it adds nothing new.

Two more small enums close out the Value Object side.

<?php

enum BookingStatus
{
    case Pending;
    case Confirmed;
    case Cancelled;
}

enum ChargeType
{
    case RoomRate;
    case CancellationPenalty;
}
Enter fullscreen mode Exit fullscreen mode

That's every Value Object. Now the one piece of this domain that isn't one.

Charge has identity because two identical-looking charges can still be different charges.

<?php

declare(strict_types=1);

namespace App\Booking\Domain;

final class Charge
{
    private bool $voided = false;

    public function __construct(
        public readonly ChargeId $id,
        public readonly ChargeType $type,
        public readonly Money $amount,
        public readonly string $description,
    ) {
    }

    public function void(): void
    {
        $this->voided = true;
    }

    public function isVoided(): bool
    {
        return $this->voided;
    }
}
Enter fullscreen mode Exit fullscreen mode

Two identical cleaning fees applied on two different days have the same type, amount and description, and are still two different charges. ChargeId is what tells them apart, not their attributes, which is the actual definition of an Entity. Charge also has a lifecycle: it can be voided after the fact without becoming a different charge. That's why it isn't readonly at the class level, only its own identity and the fields that never change are.

Booking is the only thing allowed to create or void a Charge.

<?php

declare(strict_types=1);

namespace App\Booking\Domain;

final class Booking
{
    /** @var Charge[] */
    private array $charges = [];

    private function __construct(
        public readonly BookingId $id,
        public readonly DateRange $stay,
        private BookingStatus $status,
    ) {
    }

    public static function request(BookingId $id, DateRange $stay, Money $roomRate): self
    {
        $booking = new self($id, $stay, BookingStatus::Pending);

        $booking->charges[] = new Charge(
            ChargeId::generate(),
            ChargeType::RoomRate,
            $roomRate,
            sprintf('%d night(s) at room rate', $stay->nights()),
        );

        return $booking;
    }

    public function confirm(): void
    {
        if ($this->status !== BookingStatus::Pending) {
            throw new \DomainException('Only a pending booking can be confirmed.');
        }

        $this->status = BookingStatus::Confirmed;
    }

    public function cancel(\DateTimeImmutable $now): void
    {
        if ($this->status === BookingStatus::Cancelled) {
            return;
        }

        $hoursUntilCheckIn = ($this->stay->checkIn->getTimestamp() - $now->getTimestamp()) / 3600;

        if ($hoursUntilCheckIn < 48) {
            $this->charges[] = new Charge(
                ChargeId::generate(),
                ChargeType::CancellationPenalty,
                $this->roomRateCharge()->amount->percentage(50),
                'Late cancellation penalty (less than 48h before check-in)',
            );
        }

        $this->status = BookingStatus::Cancelled;
    }

    public function voidCharge(ChargeId $chargeId): void
    {
        foreach ($this->charges as $charge) {
            if ($charge->id->equals($chargeId)) {
                $charge->void();

                return;
            }
        }

        throw new \DomainException('No such charge on this booking.');
    }

    public function total(): Money
    {
        return array_reduce(
            array_filter($this->charges, fn (Charge $c) => !$c->isVoided()),
            fn (Money $sum, Charge $c) => $sum->add($c->amount),
            Money::fromCents(0),
        );
    }

    /** @return Charge[] */
    public function charges(): array
    {
        return $this->charges;
    }

    private function roomRateCharge(): Charge
    {
        foreach ($this->charges as $charge) {
            if ($charge->type === ChargeType::RoomRate) {
                return $charge;
            }
        }

        throw new \LogicException('A booking always has a room rate charge.');
    }
}
Enter fullscreen mode Exit fullscreen mode

The 48-hour, 50% rule lives in exactly one place. No controller, form handler or admin action can add or void a charge except by calling cancel() or voidCharge(), because $charges is private. The read-only charges() accessor is deliberately still allowed: reading the list for a report or an invoice doesn't threaten the invariant, only mutating it from outside would.

Where readonly quietly stops protecting you

Four places this exact code breaks or misleads, each one a direct consequence of what readonly doesn't cover.

1 · === still means identity, not value

$penalty1 = Money::fromCents(5000, Currency::EUR);
$penalty2 = Money::fromCents(5000, Currency::EUR);

$penalty1 == $penalty2;        // true,  PHP compares properties
$penalty1 === $penalty2;       // false, different instances
$penalty1->equals($penalty2);  // true,  the only one that means what you think
Enter fullscreen mode Exit fullscreen mode

PHPUnit's assertSame() uses ===. So does the instinct every PHP developer built up writing Entities, where identity comparison is exactly what you want. Point that same instinct at a Value Object and a passing test starts failing, or worse, an if branch that should run silently doesn't.

2 · array_unique() doesn't know what equal means

$charges = [$penalty1, $penalty2]; // same value, two instances

array_unique($charges);
// Uncaught Error: Object of class Money could not be converted to string
Enter fullscreen mode Exit fullscreen mode

array_unique() casts every element to a string to compare them. A Value Object with no __toString(), which is most of them, doesn't degrade gracefully here. It throws, in production, the first time this path actually runs with real duplicate data.

3 · readonly is shallow

final readonly class Invoice
{
    /** @param Charge[] $charges */
    public function __construct(public array $charges) {}
}

$invoice = new Invoice($booking->charges());

$invoice->charges[0]->void();  // works, Charge itself isn't readonly
$invoice->charges = [];        // fails, Error: cannot modify readonly property
Enter fullscreen mode Exit fullscreen mode

readonly stops you from pointing $charges at a different array. It does nothing to stop you reaching into that array and mutating what's inside it. The property slot is immutable; the graph behind it isn't, unless every single object in that graph is readonly too, all the way down.

4 · Doctrine hands you a brand new instance

Persist a Booking through a Doctrine custom type for Money, flush, fetch it back in a different request, and convertToPHPValue() constructs a fresh Money object from the row. Same value, new instance, same === problem as gotcha 1, except this time it only shows up after persistence. Code that passed every test in-memory can start failing the moment it touches the database, for a reason that has nothing to do with the database.

Write the contract, don't infer it

Every Value Object above defines its own equals(). That's not boilerplate, it's the actual immutability contract, written down instead of assumed. readonly is the compiler-enforced half; equals() is the half nothing enforces for you.

readonly is a constraint on assignment. equals() is a constraint on meaning. A Value Object needs both, and PHP only gives you the first one for free.

None of this shows up while the code is still sitting in one file, next to the tests written for it. It shows up at the boundaries: a test asserting equality, a cache key, a collection deduplicated after a batch job, a row round-tripped through Doctrine. Those are exactly the boundaries a Symfony application crosses constantly, and exactly where a Value Object that looks safe because it's readonly stops being safe. Model the aggregate properly, Entities where identity matters, Value Objects where it doesn't, then finish the job every one of them still needs.


I write about PHP, Symfony and domain modeling at php-freelance.com/blog — this is the first of a series using the same booking domain. The next one covers domain events: recording them on the aggregate instead of publishing them inline, and why that ordering matters.

Top comments (0)