Readonly Properties (PHP 8.1)
A readonly property is a typed class property that can be assigned exactly once and never again. Before PHP 8.1, "immutable" objects were a matter of discipline — you declared a property private, only ever wrote to it inside the constructor, and trusted every future maintainer (including yourself) to keep following that rule. readonly turns that discipline into a language-level guarantee: PHP itself throws an error the moment code tries to reassign a readonly property after its initial write, no matter where that reassignment attempt comes from.
Declaring a readonly property
The readonly modifier sits alongside visibility and the type declaration. A readonly property must be typed — PHP does not allow an untyped readonly property, because "no value yet" and "already initialized" need to be distinguishable states.
A simple readonly property
<?php
class Point
{
public readonly float $x;
public readonly float $y;
public function __construct(float $x, float $y)
{
$this->x = $x;
$this->y = $y;
}
}
$origin = new Point(0.0, 0.0);
echo "{$origin->x}, {$origin->y}\n";0, 0
Most of the time you'll see readonly properties declared with PHP's constructor property promotion instead, which folds the property declaration and the constructor parameter into a single line.
Readonly with constructor promotion
<?php
class Point
{
public function __construct(
public readonly float $x,
public readonly float $y,
) {
}
}
$p = new Point(3.5, -2.0);
echo "{$p->x}, {$p->y}\n";3.5, -2
Initialized once, from inside the declaring class
"Once" is stricter than it sounds. A readonly property can only be written to a single time, and that write must happen from within the scope of the class that declared the property — not a subclass, not an external caller, not even another method on the same object after the first assignment has already happened. Trying to assign to it a second time, from anywhere, raises an Error.
A second assignment throws
<?php
class Point
{
public function __construct(
public readonly float $x,
public readonly float $y,
) {
}
public function tryToMove(float $newX): void
{
$this->x = $newX; // second write to $x — not allowed
}
}
$p = new Point(1.0, 1.0);
try {
$p->tryToMove(9.0);
} catch (\Error $e) {
echo 'Caught: ' . $e->getMessage() . "\n";
}
echo $p->x, "\n";Caught: Cannot modify readonly property Point::$x 1
Notice this failed inside a method defined on Point itself — the "once" rule is about the property's lifetime, not about which piece of code is doing the writing. Once $x was set in the constructor, it is permanently settled for that object instance.
External mutation attempts also throw
<?php
class Point
{
public function __construct(
public readonly float $x,
public readonly float $y,
) {
}
}
$p = new Point(4.0, 4.0);
try {
$p->x = 10.0;
} catch (\Error $e) {
echo 'Caught: ' . $e->getMessage() . "\n";
}Caught: Cannot modify readonly property Point::$x
This is the more common case in practice — code outside the class
(or in a subclass) reaching for $p->x = ... and being stopped
immediately, rather than silently corrupting an object that other
parts of the program assumed was fixed.
The main use case: immutable value objects and DTOs
Readonly properties shine on value objects and data transfer objects (DTOs) — small classes whose entire purpose is to bundle a handful of related values together and hand them around safely. Money amounts, coordinates, date ranges, and API request/response payloads are classic examples: once such an object is constructed, there is rarely a legitimate reason for its fields to change, and there's real value in the type system guaranteeing they can't.
A DTO that can never drift after construction
<?php
final class Money
{
public function __construct(
public readonly int $amountInCents,
public readonly string $currency,
) {
}
public function add(Money $other): self
{
if ($this->currency !== $other->currency) {
throw new \InvalidArgumentException('Currency mismatch');
}
// Returns a brand new instance instead of mutating this one.
return new self($this->amountInCents + $other->amountInCents, $this->currency);
}
}
$price = new Money(1999, 'USD');
$shipping = new Money(500, 'USD');
$total = $price->add($shipping);
echo "{$price->amountInCents} {$price->currency}\n";
echo "{$total->amountInCents} {$total->currency}\n";1999 USD 2499 USD
add() never touches $price's fields — it can't, they're readonly — so it returns a new Money instead. Callers can pass $price around freely, store it, share it across threads of logic in a request, and never worry that some distant piece of code quietly changed the amount underneath them. That guarantee is the entire point: immutability by construction removes a whole class of "who changed this and when" bugs from the debugging process.
Readonly properties holding objects
It's worth being precise about what readonly actually locks down: it prevents the property itself from being reassigned to point at a different value, but if the value is an object, that object's own internal state is not automatically frozen too.
readonly protects the binding, not the object's internals
<?php
class Tags
{
private array $items = [];
public function add(string $tag): void
{
$this->items[] = $tag;
}
public function all(): array
{
return $this->items;
}
}
class Article
{
public function __construct(
public readonly string $title,
public readonly Tags $tags,
) {
}
}
$article = new Article('PHP 8.1 Features', new Tags());
$article->tags->add('php'); // allowed — mutates the Tags object, not the property
print_r($article->tags->all());Array
(
[0] => php
)$article->tags = new Tags() would fail, exactly like the earlier
examples. But $article->tags->add(...) succeeds, because it
never reassigns the tags property — it just calls a method on
the object that property already points to. For true, deep
immutability you need every mutable collaborator to also be
immutable (or to expose no mutating methods at all).
A readonly property must be typed — PHP has no untyped readonly property.
It can be assigned exactly once, and only from within the scope of the class that declared it.
Any later assignment attempt, from inside or outside the class, throws an
Error.readonlylocks the property binding, not the internal state of an object stored in it.The main real-world use is immutable value objects and DTOs — money, coordinates, request/response payloads, and similar bundles of fixed data.