- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You ship a Money value object. PHP 8.3 just landed readonly class, so you mark the whole thing readonly and feel clever. Two sprints later, somebody on the team adds class TaxedMoney extends Money. The PR is green. Tests pass. Then your serializer starts throwing Error: Cannot modify readonly property in production at 2am on a Tuesday.
That's trap one of three. The other two are quieter. They don't throw. They silently break clone-with semantics and freeze Symfony Serializer into a state nobody can decode.
This post walks the three traps with a real Money and Address example, shows why composition usually wins for value objects, and ends with the PHP 8.4 escape hatch that lets you keep inheritance when you actually need it.
The promise: readonly class Money looked perfect
The readonly class syntax in PHP 8.3 is genuinely nice. You write this:
readonly class Money
{
public function __construct(
public int $amountCents,
public string $currency,
) {}
public function add(Money $other): self
{
if ($other->currency !== $this->currency) {
throw new \DomainException('currency mismatch');
}
return new self($this->amountCents + $other->amountCents, $this->currency);
}
}
No private properties, no getters, no final keyword on every property. The class is immutable end-to-end. You can't accidentally mutate it because the language won't let you. For value objects like Money, Address, Email, Coordinate, it looks like the feature you've been waiting for since PHP 7.
It is. Right up until the moment somebody tries to extend it.
Trap 1: inheritance only works in one direction
Here's the rule the migration page doesn't make obvious: a readonly class can only be extended by another readonly class. And a non-readonly class can only be extended by a non-readonly class. The two worlds don't mix.
Try this:
readonly class Money
{
public function __construct(
public int $amountCents,
public string $currency,
) {}
}
// fatal error: Non-readonly class TaxedMoney cannot extend readonly class Money
class TaxedMoney extends Money
{
public function __construct(
int $amountCents,
string $currency,
public int $taxCents,
) {
parent::__construct($amountCents, $currency);
}
}
PHP throws at class-load time:
Fatal error: Non-readonly class TaxedMoney cannot extend readonly class Money
OK, mark the child readonly:
readonly class TaxedMoney extends Money
{
public function __construct(
int $amountCents,
string $currency,
public int $taxCents,
) {
parent::__construct($amountCents, $currency);
}
}
That compiles. Now try it the other way: a non-readonly parent, a readonly child.
class Address
{
public function __construct(
public string $street,
public string $city,
) {}
}
// fatal error: Readonly class BillingAddress cannot extend non-readonly class Address
readonly class BillingAddress extends Address {}
Same kind of fatal. The check is symmetric. The compiler refuses to let a readonly class and a non-readonly class share a chain, in either direction.
That sounds like a small inconvenience. It isn't. It means the moment you mark Money as a readonly class, you've cut off every framework class, every package class, and every legacy domain class in your codebase from ever being its parent. You also can't refactor an existing class hierarchy to readonly piecewise. You either flip the whole tree or none of it.
Trap 2: clone-with semantics change silently
The standard PHP immutable pattern is "clone and mutate":
public function withCurrency(string $currency): self
{
$clone = clone $this;
$clone->currency = $currency;
return $clone;
}
That works on a class with private properties and a method that mutates them post-clone. It does NOT work on a readonly class. Watch:
readonly class Money
{
public function __construct(
public int $amountCents,
public string $currency,
) {}
public function withCurrency(string $currency): self
{
// Error: Cannot modify readonly property Money::$currency
$clone = clone $this;
$clone->currency = $currency;
return $clone;
}
}
Run that and PHP throws at runtime. The readonly modifier extends to the clone. You can't write to the cloned object's properties either. The official replacement is "build a new instance":
public function withCurrency(string $currency): self
{
return new self($this->amountCents, $currency);
}
Fine for two fields. Painful when Address has eight. And here's the silent part: if BillingAddress extends Address and only BillingAddress is readonly (or worse, if the parent's properties aren't marked readonly individually), clone followed by direct property write succeeds on the parent's fields and fails on the child's. The behavior is split mid-object. The error message points at one property, you fix that one, and the next clone call fails on a different one.
PHP 8.4 fixes the ergonomics with clone with:
public function withCurrency(string $currency): self
{
return clone($this, ['currency' => $currency]);
}
That works on readonly classes. The language carved out an explicit exception for the clone with syntax. The assignment inside the expression is allowed to write to readonly properties during the clone. It's the only place that's true.
If you're on PHP 8.3, you don't have clone with. You either rewrite every with* method as a full constructor call or you wait for 8.4. There's no third option that the language supports.
Trap 3: serializers explode on private readonly properties
This one ships to production and bites on a real customer payload. Symfony Serializer (and JMS Serializer, and most reflection-based hydration libraries) deserialize by creating an empty instance via ReflectionClass::newInstanceWithoutConstructor(), then writing property values one by one through reflection.
That pattern is the exact thing readonly was designed to prevent.
readonly class Address
{
public function __construct(
public string $street,
public string $city,
public string $country,
public string $postalCode,
) {}
}
$json = '{"street":"Maximilianstrasse 1","city":"Munich","country":"DE","postalCode":"80539"}';
$address = $serializer->deserialize($json, Address::class, 'json');
// Error: Cannot modify readonly property Address::$street
The Symfony\Component\Serializer\Normalizer\ObjectNormalizer in Symfony 6.4 and 7.x has special-case handling for readonly properties. It tries to call the constructor with the matching argument names instead of writing properties directly. That works when your JSON keys match constructor parameter names exactly. The moment they don't (a snake_case API talking to a camelCase PHP class, a renamed field, a polymorphic discriminator), it falls back to direct property write and throws.
JMS Serializer (still widely used) does not have the constructor fallback. It throws on every readonly class unless you configure a custom PropertyMetadata strategy. Doctrine ORM hydration was patched in 2.17 to use reflection-based construction for readonly entities, but third-party packages that hook into Doctrine's hydration pipeline (audit-log packages, event-sourcing tooling) don't always know.
The fix that ships in 2026 codebases is: keep value objects readonly, but write a small DTO layer that does the deserialization, then construct the value object via its real constructor. Don't ask the serializer to hydrate the domain model directly.
final class AddressDto
{
public string $street;
public string $city;
public string $country;
public string $postalCode;
public function toDomain(): Address
{
return new Address($this->street, $this->city, $this->country, $this->postalCode);
}
}
That's an extra class. It's also the right boundary. Your value object stops being a deserialization target.
The fix: composition over inheritance for value objects, period
After ten years of refactoring PHP codebases, the lesson is: value objects don't have subclasses. They have variants, and variants are different types.
final readonly class Money
{
public function __construct(
public int $amountCents,
public string $currency,
) {}
}
final readonly class TaxedAmount
{
public function __construct(
public Money $net,
public Money $tax,
) {}
public function gross(): Money
{
return $this->net->add($this->tax);
}
}
TaxedAmount doesn't extend Money. It contains two of them. The behavior you'd have put in TaxedMoney::gross() lives on TaxedAmount::gross() and composes the operation from Money::add(). You lose nothing. You gain: no serialization trap, no clone-with trap, no inheritance-direction trap, no shared mutation surface.
The same shape works for Address:
final readonly class Address
{
public function __construct(
public string $street,
public string $city,
public string $country,
public string $postalCode,
) {}
}
final readonly class BillingAddress
{
public function __construct(
public Address $address,
public string $vatNumber,
) {}
}
BillingAddress is not an Address. It has one. The Liskov question never comes up because there's no substitution to break.
Mark every value object final readonly. The final keyword tells the next developer the class is not meant to be extended. The readonly keyword tells the language to enforce immutability. The combination is what you want 95% of the time.
The PHP 8.4 escape hatch: asymmetric visibility
There's a real case where you want inheritance and immutability together: domain aggregates that need a hierarchy. PHP 8.4 ships asymmetric visibility, which lets you keep public-read / private-write semantics without using readonly class at all.
abstract class Order
{
public function __construct(
public private(set) string $id,
public private(set) OrderStatus $status,
) {}
protected function transitionTo(OrderStatus $next): void
{
$this->status = $next; // allowed inside the class
}
}
final class SubscriptionOrder extends Order
{
public function renew(): void
{
$this->transitionTo(OrderStatus::Active);
}
}
public private(set) means anyone can read status, only code inside the class hierarchy can write it. The properties aren't readonly, so you sidestep all three traps. You also get the inheritance you actually want.
Serializers handle this cleanly because the property write happens through reflection, which honors private(set) only on the public surface. Reflection-based writes work via ReflectionProperty::setValue() on PHP 8.4 when the appropriate access mode is requested.
The pattern: final readonly class for value objects, public private(set) for entities and aggregates that need a controlled mutation surface and possibly a class hierarchy.
When final readonly class is the right answer
It's the right answer when the class meets all four:
- The class represents a value, not an identity. (Two
Money(1000, 'EUR')instances are equal; twoOrderinstances with the same ID are the same order.) - The class has no subclasses today and shouldn't grow any.
- All properties are set once at construction and never change for the object's lifetime.
- Deserialization either goes through a DTO or through a constructor-aware serializer.
If any of those four are false, drop readonly class and reach for final readonly class on individual properties, or public private(set) if you need a hierarchy. The keyword combinations are easy to remember once you know what each one buys you.
Which of the three traps has bitten you in production? Drop the story in the comments.
If this was useful
The readonly trap is one of those PHP-language details that bites the moment you try to take your codebase past framework defaults. Most of the time the fix isn't a clever keyword combo. It's stricter boundaries between your domain layer and the libraries that hydrate it. That boundary discipline is what Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework is built around: where the value object lives, where the DTO lives, what crosses, and what doesn't.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)