The Readonly Trap
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;
}
}
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';
}
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.');
}
}
}
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;
}
}
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;
}
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;
}
}
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.');
}
}
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. That distinction, and where it quietly stops holding, is exactly
gotcha 3, below.
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.
$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
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.
$charges = [$penalty1, $penalty2]; // same value, two instances
array_unique($charges);
// Uncaught Error: Object of class Money could not be converted to string
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.
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
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.
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.
readonlyis 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.
If this is the kind of review your own aggregates could use, that's close to exactly what a code audit is for: an outside read on whether the model actually holds up, before it's your production data that finds the gap.