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 errorsThe 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 throwsThe 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.