PHPReadonly Properties (PHP 8.1)

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
<?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
<?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
<?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
<?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
<?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
<?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).

readonly is not the same as a getter-only property with a private setter
A common instinct is to reach for `readonly` as a shortcut for "public read, private write." That's close but not quite right: `readonly` doesn't just restrict writes to `private` visibility — it forbids every write after the first one, even from inside the declaring class itself. If you need a property that a class can update internally but that outside code can only read, you still want a private property plus a public getter method, not `readonly`.
  • 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.

  • readonly locks 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.

Tip
When a class holds several readonly properties that logically belong together (like `x`/`y` on a point, or `amountInCents`/ `currency` on a money value), give it methods like `withX()` or `add()` that return a *new* instance rather than trying to mutate the existing one. That "modify by returning a copy" style pairs naturally with `readonly` and keeps the immutability guarantee intact throughout your codebase, not just at the point of construction.