Readonly classes and the DTO that stopped needing a constructor

The domain layer had adopted readonly properties in December 2021 and a year later had ninety-one classes where every property carried the keyword. PHP 8.2 lets it be written once on the class, which is a smaller change than it sounds and has one property nobody expects.

The symptom

// 8.1, and ninety-one classes shaped like this
final class OrderLine
{
    public function __construct(
        public readonly Sku $sku,
        public readonly int $quantity,
        public readonly Money $unitPrice,
        public readonly ?Discount $discount,
    ) {}
}

// four keywords saying what the class as a whole means,
// and a fifth property added next month that somebody
// will forget to mark.

The forgotten fifth property is the actual problem rather than the verbosity: a class that is immutable in four fields and mutable in one is worse than either, and nothing about the declaration makes the omission visible.

Why it happens

Immutability was a property of each field because that is where the keyword applied, and the intent — this class does not change after construction — had no expression. Every new property was an opportunity to break it.

The fix

The class modifier

final readonly class OrderLine
{
    public function __construct(
        public Sku $sku,
        public int $quantity,
        public Money $unitPrice,
        public ?Discount $discount,
    ) {}
}

// and the property that is not obvious: the modifier
// applies to every property declared by a CHILD class too.
// it is inherited downwards, not a shorthand at
// declaration.

Inheritance of the modifier is what makes this a statement about a hierarchy rather than a class, and it is the detail that changes how it should be used. A readonly base class constrains every subclass whether or not the author of the subclass knows.

The three refusals

// 1. untyped properties are refused
readonly class Config
{
    public $value;   // Fatal: must have type
}

// 2. a readonly class cannot extend a non-readonly one,
//    and the reverse is also refused
class Mutable {}
readonly class Tight extends Mutable {}   // Fatal
readonly class Base {}
class Loose extends Base {}                // Fatal

// 3. and the exception: STATIC properties are excluded
readonly class WithCounter
{
    public static int $count = 0;   // legal, and MUTABLE
}

The symmetric inheritance refusal is stricter than expected and is correct: a readonly class extending a mutable one would make the parent’s own methods illegal, because they write to properties that are now readonly. That makes readonly a decision about a whole hierarchy.

The static exception is the one to write down. A readonly class with a mutable static counter is legal and looks immutable to anybody reading the declaration, which is exactly the kind of gap that produces a bug in a long-running process.

DNF types, which arrived in the same release

// 8.1: pure intersection types, and no way to be nullable
function f(Countable&Traversable $rows) {}
function g(?(Countable&Traversable) $rows) {}   // parse error

// 8.2: a union OF intersections
function f((Countable&Traversable)|null $rows) {}
function g((A&B)|(C&D)|int $x) {}
function h((A|B)&C $x) {}   // still refused: parse error

The parentheses are mandatory around every intersection, even where precedence would be unambiguous, which reads as noise and removes a category of misreading. This closes the gap that made intersection types awkward in 8.1 — the nullable case is the one everybody hit first.

The clone problem, still

// unchanged in 8.2: clone copies the initialised state,
// so modifying the copy throws
public function withQuantity(int $q): self
{
    $copy = clone $this;
    $copy->quantity = $q;      // Error: already initialised

    return $copy;
}

// so a wither is still a constructor call, and on a class
// with six properties it lists all six
public function withQuantity(int $q): self
{
    return new self($this->sku, $q, $this->unitPrice, $this->discount);
}

clone with arrives in 8.3 and until then the constructor call is the only correct form, which gets verbose fast. A private helper spreading an overrides array into named arguments contains the repetition and introduces a coupling between parameter names and array keys — acceptable because the array never leaves the class.

The migration, which is a script

# the rule: a final class where EVERY property is readonly
# becomes a readonly class, and the keywords come off
$ vendor/bin/rector process src/Domain 
    --config=rector-readonly-class.php

$ git diff --stat | tail -1
 91 files changed, 188 insertions(+), 412 deletions(-)

$ vendor/bin/phpunit && vendor/bin/phpstan analyse
Tests: 1,412 passed
 [OK] No errors

The conversion is mechanical and the review is the interesting part: two of the ninety-one had a property that was deliberately not readonly, which the rule correctly skipped and which turned out to be a mutable field on a class named as a value object. Both were bugs.

What it does not give you

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

$b->lines[] = $line;        // Error — arrays are values
$b->customer->name = 'x';   // FINE. the reference is
                            // readonly; the object is not.

A readonly property holding a mutable object gives an immutable reference to a mutable thing, which is a guarantee nobody wants and is unchanged by the class modifier. The only fix is that contained objects are themselves immutable all the way down, and nothing checks that — it is a discipline with a keyword that looks like enforcement.

Verifying it worked

$ grep -rc 'public readonly' src/Domain | awk -F: '{s+=$2} END {print s}'
0

$ grep -rc 'readonly class' src/Domain | awk -F: '{s+=$2} END {print s}'
91

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

$ vendor/bin/phpunit --filter Immutab
OK (91 tests, 91 assertions)
#   one per class: assigning to a property throws

The per-class immutability test looks redundant against a language feature and earns its place by catching the case where somebody removes the modifier during a refactor to make a hydrator work. That is a change no other test notices.

What this costs

A minimum version that is three weeks old at the time of adoption, which for an internal application is a decision to make once and for a library is a decision on behalf of every consumer. This codebase adopted it in the same release as the 8.2 upgrade because both were one-way doors and doing them separately bought nothing.

The hierarchy constraint is the other cost and it is the one that will be met later: a readonly class cannot be extended by a mutable one, so a value object that a subclass wants to add a mutable cache to is now a refactor rather than a property. That is the correct answer and it will read as an obstruction at the moment it is met.