PHP readonly Properties
A readonly property is a class property that can be written to exactly once, and only from inside the scope of the class that declares it. Once initialized, any further attempt to change it — from outside the object or even from within another method of the same class — throws an Error. Introduced in PHP 8.1, readonly properties let you build immutable value objects (money amounts, coordinates, DTOs, configuration snapshots) without writing boilerplate guard code yourself.
Overview / How it works
Before PHP 8.1, “immutability” in PHP was a convention, not a guarantee. You could mark a property private and refuse to write a setter, but nothing stopped a method inside the class from mutating it later, and reflection could always bypass visibility anyway. readonly makes immutability a language-level rule enforced by the Zend engine itself.
Internally, every object has, alongside its normal property table, a bit of bookkeeping the engine uses to track which readonly properties have already been initialized. The very first time a value is written to a readonly property from the declaring class’s scope, the engine flips that property’s “initialized” flag. Every subsequent write attempt — regardless of where it comes from — is checked against that flag. If the flag is already set, the engine throws Error: Cannot modify readonly property ClassName::$propertyName before the assignment ever happens. If you try to write to it from outside the declaring class (even a subclass, for a property declared in the parent), you instead get Error: Cannot initialize readonly property ClassName::$propertyName from global scope or a similar scope-violation message.
A few rules follow directly from this design:
- A readonly property must have a type declaration — untyped properties can’t be readonly, because the engine needs a concrete slot to track initialization state against.
- A readonly property cannot have a default value in its declaration, since a default would count as the one allowed initialization, leaving nothing left for the constructor to set.
- Once set, a readonly property can never be reset — not by the class itself, not by a subclass, not by
ReflectionProperty::setValue()in ordinary use (reflection can bypass this only via a special unserialize-style API meant for object cloning frameworks). - Readonly applies to the property binding itself, not to the value it holds. If a readonly property holds an array or an object, the property can’t be reassigned, but a mutable object stored inside it can still be mutated through its own methods. This is a common source of confusion — see the Common Mistakes section.
Since PHP 8.2, you can also mark an entire class readonly (readonly class Foo { ... }), which makes every typed property in the class readonly automatically, including ones added later. It’s a convenient shorthand for value-object classes where every property should be immutable.
Syntax
<?php
class ClassName {
public readonly Type $propertyName;
public function __construct(Type $propertyName) {
$this->propertyName = $propertyName;
}
}
// Constructor property promotion shorthand
class ClassName {
public function __construct(
public readonly Type $propertyName,
) {}
}
// PHP 8.2+: mark the whole class readonly
readonly class ClassName {
public function __construct(
public Type $propertyName,
) {}
}
| Part | Meaning |
|---|---|
readonly |
Modifier placed before or after the visibility keyword (public readonly or readonly public) that restricts the property to a single write. |
| Type declaration | Required. Readonly properties must be typed — mixed is allowed, but no type at all is not. |
| Visibility | public, protected, or private — readonly works with any of them; it constrains writes, not reads. |
| Initialization scope | The property may only be assigned from inside the class that declared it (typically the constructor). |
readonly class |
PHP 8.2+: applies readonly to every typed property declared in the class. |
Examples
Example 1: A simple immutable value object
<?php
class Point {
public readonly float $x;
public readonly float $y;
public function __construct(float $x, float $y) {
$this->x = $x;
$this->y = $y;
}
}
$p = new Point(3.5, 7.2);
echo "Point: ({$p->x}, {$p->y})\n";
try {
$p->x = 10.0;
} catch (Error $e) {
echo "Error: " . $e->getMessage() . "\n";
}
Point: (3.5, 7.2)
Error: Cannot modify readonly property Point::$x
The constructor is allowed to set $x and $y because it runs inside the class’s own scope. The later attempt to overwrite $p->x from outside the class is rejected immediately — the engine throws before the assignment can take effect, so $p->x is still 3.5 afterward.
Example 2: Constructor promotion and “wither” methods
<?php
final class User {
public function __construct(
public readonly int $id,
public readonly string $name,
public readonly string $email,
) {}
public function withEmail(string $email): self {
return new self($this->id, $this->name, $email);
}
}
$user = new User(1, "Ada Lovelace", "ada@example.com");
echo "{$user->name} <{$user->email}>\n";
$updated = $user->withEmail("ada.lovelace@example.com");
echo "{$updated->name} <{$updated->email}>\n";
echo "Original still: {$user->email}\n";
Ada Lovelace <ada@example.com>
Ada Lovelace <ada.lovelace@example.com>
Original still: ada@example.com
Combining readonly with constructor property promotion is the idiomatic PHP 8.1+ way to declare an immutable value object in one line per property. Since the object can never be mutated in place, the standard pattern for “changing” a value is a wither method like withEmail(): it builds and returns a brand new instance, leaving the original untouched.
Example 3: A readonly class (PHP 8.2+)
<?php
readonly class Money {
public function __construct(
public int $cents,
public string $currency,
) {}
public function add(Money $other): self {
if ($this->currency !== $other->currency) {
throw new InvalidArgumentException("Currency mismatch");
}
return new self($this->cents + $other->cents, $this->currency);
}
public function __toString(): string {
return number_format($this->cents / 100, 2) . " " . $this->currency;
}
}
$price = new Money(1500, "USD");
$tax = new Money(120, "USD");
$total = $price->add($tax);
echo "Price: {$price}\n";
echo "Total: {$total}\n";
Price: 15.00 USD
Total: 16.20 USD
Marking the whole class readonly saves repeating the modifier on every property — $cents and $currency are both readonly automatically. Notice add() doesn’t mutate $this; it can’t, since the properties are readonly, so it returns a new Money instance instead, which is exactly the behavior you want from a value object representing an amount of money.
How it works step by step / Under the hood
It’s a common misconception that readonly only blocks writes from outside the class. It actually blocks any write after the first one, full stop — including from inside the declaring class itself:
<?php
class Counter {
public readonly int $value;
public function __construct(int $value) {
$this->value = $value;
}
public function reset(): void {
$this->value = 0; // still fails, even though we're inside the class
}
}
$c = new Counter(5);
try {
$c->reset();
} catch (Error $e) {
echo "Error: " . $e->getMessage() . "\n";
}
Error: Cannot modify readonly property Counter::$value
Step by step, here’s what the engine does on every property write:
- 1. It checks whether the property being written to is marked
readonly. - 2. If it is, it checks an internal per-object flag recording whether that specific property has already been initialized.
- 3. If the flag is unset and the current execution scope is the class that declared the property, the write proceeds and the flag is set.
- 4. If the flag is already set, or the scope doesn’t match the declaring class, the engine throws an
Errorinstead of performing the write.
This is also why readonly properties work well with clone. Cloning creates a new object and, as of PHP 8.3, the __clone() magic method is allowed to reinitialize readonly properties on the freshly cloned copy (they count as “uninitialized” again on the new object inside __clone()), which makes patterns like withEmail() from Example 2 possible to implement via cloning too, not just via new self(...).
Common Mistakes
Mistake 1: Giving a readonly property a default value
class Config {
public readonly string $env = "production";
}
This looks harmless but is rejected with Fatal error: Readonly property Config::$env cannot have default value. A default value would count as the property’s one and only initialization, which defeats the purpose of letting a constructor set it per-instance.
Corrected:
<?php
class Config {
public readonly string $env;
public function __construct(string $env = "production") {
$this->env = $env;
}
}
$config = new Config();
echo $config->env . "\n";
production
Put the default on the constructor parameter instead of the property — the property is still set exactly once, but callers can omit the argument.
Mistake 2: Writing a “setter” for a readonly property
class Article {
public readonly string $title;
public function __construct(string $title) {
$this->title = $title;
}
public function setTitle(string $title): void {
$this->title = $title;
}
}
Calling setTitle() after construction throws Error: Cannot modify readonly property Article::$title, because the property was already initialized in the constructor. Developers coming from mutable-object habits often add a setter out of reflex, then are surprised when it blows up at runtime rather than at compile time (a plain syntax check won’t catch this — it’s a valid method, it just always fails when called).
Corrected: don’t write a setter at all. Use the wither pattern from Example 2 — return a new instance instead of mutating the existing one.
Mistake 3: Assuming readonly means deeply immutable
<?php
class Container {
public function __construct(public readonly ArrayObject $items) {}
}
$container = new Container(new ArrayObject(["apple", "banana"]));
$container->items[] = "cherry";
echo count($container->items) . "\n";
3
readonly only stops the property from being reassigned to point at a different object — it does nothing to the mutability of the object the property already points to. Here $container->items still refers to the same ArrayObject instance forever, but that instance is itself mutable, so appending to it works fine. If you need true deep immutability, store an immutable data structure (or a plain array, which — unlike an object — triggers the readonly guard on indirect writes like $obj->arrayProp[] = 'x', since PHP arrays have value semantics and any write to an element counts as rewriting the whole property).
Best Practices
- Reach for
readonlywhenever a property represents a fact about an object that should never change after construction: an ID, a timestamp, an amount, a coordinate. - Combine
readonlywith constructor property promotion to keep value-object classes short and declarative. - Use a
readonly class(PHP 8.2+) when every property in the class should be immutable, instead of repeating the modifier on each one. - Prefer “wither” methods (
withX()) that return a new instance over trying to add setters — readonly properties are incompatible with the setter pattern by design. - Remember readonly is shallow: if a property holds a mutable object, document (or wrap) that object if you need the whole graph to be immutable.
- Pair
readonlywith strict, specific types rather thanmixedwhere possible — it makes the immutability guarantee much more meaningful to callers. - Don’t try to defensively “reset” state with a readonly property inside the class itself expecting it to behave like a private cache — it can’t be reinitialized once set, even internally.
Practice Exercises
- Exercise 1: Write an immutable
Temperatureclass with a singlereadonly float $celsiusproperty and a methodtoFahrenheit(): floatthat computes and returns the Fahrenheit equivalent without modifying the object. Then write code that attempts to reassign$celsiusafter construction and catches the resultingError. - Exercise 2: Refactor a mutable
Personclass that haspublic string $nameand asetName(string $name)method into an immutable version using a readonly promoted property and awithName(string $name): selfwither method. Confirm that callingwithName()leaves the original object’snameunchanged. - Exercise 3: Given a class with
public readonly array $tags, predict — without running it — what happens when code outside the class executes$obj->tags[] = "new-tag";. Explain why this differs from doing the same thing to a readonly property typed as anArrayObject.
Summary
readonlyproperties (PHP 8.1+) can be written exactly once, and only from within the declaring class’s scope — typically the constructor.- Readonly properties must be typed and cannot have a default value in their declaration.
- Any later write attempt, whether from outside the class or from another method inside it, throws an
Error. - Constructor property promotion plus
readonlyis the idiomatic way to write concise immutable value objects. - PHP 8.2 added
readonly classto apply the modifier to every typed property in a class at once. - PHP 8.3 allows
__clone()to reinitialize readonly properties on the newly cloned object, enabling clone-based wither patterns. - Readonly immutability is shallow: it locks the property binding, not the mutability of an object value stored inside it.
- The idiomatic way to “change” an immutable object is to return a new instance from a wither method rather than adding a setter.
