Readonly properties and the value object that stops defending itself

The domain layer had forty value objects and every one of them was the same shape: private properties, a constructor, and a getter per field whose only purpose was to be a read that was not also a write. That is roughly four hundred lines of code expressing “you may look at this and not change it”.

The symptom

// 8.0 — the only way to prevent mutation
final class Money
{
    public function __construct(
        private int $cents,
        private string $currency,
    ) {}

    public function cents(): int { return $this->cents; }
    public function currency(): string { return $this->currency; }
}

// forty of these. eighty getters. and the call sites:
//   $money->cents()   rather than   $money->cents

The getters carry no logic, no validation and no lazy computation. They exist because public int $cents would have been writable, and the alternative was a class whose invariants could be broken from outside.

Why it happens

PHP had no way to express a property that could be read and not written, so immutability had to be built out of visibility plus accessors. Every language with the feature makes this three words; without it, it is a pattern.

The fix

The same class

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

$m = new Money(4900, 'GBP');
$m->cents;         // 4900
$m->cents = 5000;  // Error: Cannot modify readonly property

Ten lines becomes seven and eighty getters across the domain layer become zero. The call sites lose a pair of parentheses each, which is a large mechanical diff and is mechanical enough to be done with a tool.

the exact semantics, which are narrower than they look:

  write-once, from INSIDE the declaring class
  no default value permitted — a readonly property with
    an initialiser is a compile error
  a subclass cannot initialise a parent's readonly property
  typed properties only — untyped cannot be readonly
  the second write throws, even from inside
  clone copies the initialised state, so the copy is
    already locked

Initialisation outside the constructor is legal as long as it happens inside the declaring class, which is what lets a static factory do work before assigning. The prohibition on defaults is the one that reads as arbitrary and is not: a value assigned at declaration could never change, so the language calls that a constant instead.

The wither, which is awkward in 8.1

// this does not work: clone copies the INITIALISED state
public function withCurrency(string $currency): self
{
    $copy = clone $this;
    $copy->currency = $currency;   // Error: already initialised

    return $copy;
}

// what does: a new instance, from the constructor
public function withCurrency(string $currency): self
{
    return new self($this->cents, $currency);
}

The constructor version is correct and gets verbose fast: a value object with six properties needs all six listed in every wither, and adding a seventh means editing every one of them. That is the real ergonomic cost of readonly in 8.1 and it is fixed later by clone with.

// the mitigation, for anything with more than three fields
private function with(array $overrides): self
{
    return new self(...array_merge([
        'cents'    => $this->cents,
        'currency' => $this->currency,
    ], $overrides));
}

public function withCurrency(string $c): self
{
    return $this->with(['currency' => $c]);
}

Spreading a string-keyed array into named arguments is the trick that makes this work, and it is exactly the coupling between parameter names and a public contract that named arguments introduced. It is contained here because the array is private to the class.

What it does not give you

final class Basket
{
    public function __construct(
        public readonly array $lines,
        public readonly Customer $customer,
    ) {}
}

$b->lines[] = $line;
// Error — arrays are VALUES, so this is a write to $lines

$b->customer->name = 'x';
// FINE. the reference is readonly; the object is not.

The array case works better than expected because PHP arrays are value types, so appending is a property write and is refused. The object case is the real gap: a readonly property holding a mutable object gives an immutable reference to a mutable thing, which is a guarantee nobody wants.

The only fix is that the contained objects are themselves immutable, all the way down, and nothing in the language checks that. It is a discipline with a keyword that looks like enforcement, and saying so in the code review guidance is more useful than assuming everybody knows.

Serialisation and hydration

what works, by explicit engine carve-out:
  unserialize()      may initialise readonly once
  ReflectionProperty::setValue on an UNinitialised one
  a Doctrine hydrator, for the same reason

what does not:
  clone-then-modify  (the wither problem above)
  a hydrator that constructs and then sets, on a property
    already set by the constructor
  __set on a readonly property — always an error

so: ORMs that hydrate via reflection into an uninitialised
object are fine. ones that construct and then overwrite
are not, and that is a library-by-library question.

This is the practical constraint that decides whether readonly can be adopted in the persistence layer at all, and it is worth testing rather than reading about — a hydrator that appears to work may be constructing with defaults and overwriting, which throws on the first non-null value.

Verifying it worked

$ grep -rc 'public function [a-z]*(): ' src/Domain/ValueObject/ | 
    awk -F: '{s+=$2} END {print s}'
11        # was 88

$ vendor/bin/phpstan analyse
 [OK] No errors

$ vendor/bin/phpunit --filter Immutab
OK (41 tests, 41 assertions)

# and the test that is worth writing once per class:
#   $this->expectException(Error::class);
#   $money->cents = 1;

Eighty-eight accessor methods becoming eleven is the measurable outcome, and the eleven that remain are the ones with actual behaviour — a formatted string, a derived total, a comparison. Those were always the methods worth having and they had been indistinguishable from the eighty that were not.

The immutability test per class looks redundant against a language feature and is worth the line: it catches the case where somebody removes readonly during a refactor to make a hydrator work, which is a change that no other test notices.

What this costs

A class that cannot be hydrated by every ORM, which is a constraint imposed on the persistence layer by a decision in the domain layer. On this codebase the hydrator used reflection into an uninitialised object and worked; on the next one it might not, and finding out is an afternoon before committing to the approach.

The wither verbosity is the day-to-day cost and it is genuinely annoying on anything with more than three properties. The array-spread mitigation works and is a small piece of cleverness in a class that had none, which is a real trade against readability — and the alternative, waiting for clone with, means not adopting readonly for another three years.