01 · THE IDEAREAD → TRY → LEARN
Why readonly exists
Some values should never change after an object is created: an order total, a coordinate, a user ID. If any method or caller can overwrite them, every piece of code that receives the object has to wonder whether it is still valid. The readonly modifier moves that guarantee into the language. A readonly property may be written once, from inside the class that declares it. After that, any attempt to change it throws an Error.
The rules
- A readonly property must have a type. If you really want any value, use
mixed. - It cannot have a default value, so it must be initialized by the class itself, usually in the constructor.
- Static properties cannot be readonly.
- Initialization must be a direct assignment. Passing the property by reference is an error.
- Initialization from outside the declaring class scope, for example from global code, is an error.
- Once initialized, the property cannot be reassigned or
unset(), and compound changes such as+=,++or[] =on it also fail.
In PHP 8.4, a readonly property is implicitly protected(set), so child classes may also initialize it. This lesson keeps to the simplest case, where the declaring class does the initializing.
A first look
Constructor promotion keeps the declaration and the single assignment together. Reading is always allowed, so the property can stay public. Writing is what is locked down.
final class Badge {
public function __construct(public readonly string $label) {}
}
$badge = new Badge("Admin");
echo $badge->label, "\n";
try {
$badge->label = "Owner";
} catch (Error $e) {
echo $e->getMessage(), "\n";
}Admin Cannot modify readonly property Badge::$label
Note that assigning the same value again would still fail. The rule is about the act of writing, not about whether the value differs.
Changing by copying
If the value cannot change, how do you represent a new state? You create a new object. Methods that do this are often named with a with prefix. The original stays exactly as it was, so anyone still holding it is unaffected.
final class Point {
public function __construct(
public readonly int $x,
public readonly int $y,
) {}
public function withX(int $x): self {
return new self($x, $this->y);
}
}
$a = new Point(1, 2);
$b = $a->withX(10);
echo json_encode(["a" => [$a->x, $a->y], "b" => [$b->x, $b->y]]), "\n";{"a":[1,2],"b":[10,2]}
Readonly is shallow
Readonly protects the property binding, not everything reachable through it. If the property holds an object, you cannot replace that object, but you can still change the object's own state. This is called interior mutability. If you need deep immutability, store scalars, arrays you never modify, or other readonly objects.
final class Box {
public function __construct(public readonly stdClass $item) {}
}
$box = new Box(new stdClass);
$box->item->count = 1;
$box->item->count = 2;
echo $box->item->count, "\n";
try {
$box->item = new stdClass;
} catch (Error $e) {
echo $e->getMessage(), "\n";
}2 Cannot modify readonly property Box::$item
Common mistakes
- Giving a readonly property a default such as
= 0. This is a fatal error; assign it in the constructor instead. - Forgetting the type declaration.
- Trying
$this->total += 5inside a method. That is a modification, so it throws even inside the class. - Assuming an object held in a readonly property is frozen.
- Leaving a readonly property uninitialized and then reading it. Typed properties must be initialized before access.
Exercise contract
Write immutableCounter(int $start, array $steps): array. Define a small final class Counter with a promoted public readonly int $value and a method add(int $by): self that returns a new Counter. Start with a counter holding $start. For each integer in $steps, replace your current counter with add($step) and record its value.
Return an array with three keys: original is the value of the first counter, which must be unchanged; final is the value of the last counter; and values is the list of values after each step. With no steps, final equals original and values is [].
Recap
Readonly properties are typed, have no default, and are written once from the declaring class. Later writes throw Error. Model change by returning new objects, and remember that readonly does not freeze objects stored inside it.
Functions · Arrays · Types
- Initialize typed properties exactly once
- Implement and test a deterministic PHP 8.4 function