PHP Readonly Properties and Classes: Immutability Complete Guide

PHP introduced readonly properties in 8.1 and readonly classes in 8.2. This post covers both: what they are, why immutability matters, how readonly syntax compares to the traditional getter pattern, and when to use each approach.
The Case for Immutability
As a codebase grows, mutability becomes a liability. An object whose state can be changed at any point by any caller forces you to track every possible mutation path when debugging. Immutable objects eliminate this: once created, their state is known and fixed.
The concrete benefits:
- Predictability: No hidden state changes. The object you receive is the object you inspect.
- Thread safety: Immutable objects can be shared across threads without locks - nothing can change them.
- Cheap equality checks: Two instances are only equal if they are the same instance (strict
===). No need to compare every property. - Change tracking: Each mutation produces a new object. You can keep a history of states trivially.
- Testability: Stateless or immutable code is easier to test - no setup/teardown of mutable state between tests.
The trade-offs: every mutation allocates a new instance. For tightly-looped code this can matter, but for typical DTOs, entities, and value objects in web applications it is not a practical concern.
The Traditional Pattern: Private Properties + Getters
Before PHP 8.1, the idiomatic way to approximate immutability was constructor injection with private properties and public getters:
<?php
class Car {
private Engine $engine;
private int $doors;
public function __construct(Engine $engine, int $doors) {
$this->engine = $engine;
$this->doors = $doors;
}
public function getEngine(): Engine {
return $this->engine;
}
public function getDoors(): int {
return $this->doors;
}
}
This works, but it is enforced by convention only. Nothing stops a subclass from overriding the properties, and there is no language-level guarantee. It also produces a lot of boilerplate.
PHP 8.1: Readonly Properties
PHP 8.1 introduced the readonly keyword for properties. A readonly property can only be assigned once - typically in the constructor - and cannot be modified afterwards, even from within the class itself:
<?php
class Car {
public function __construct(
private readonly Engine $engine,
private readonly int $doors,
) {}
public function getEngine(): Engine {
return $this->engine;
}
public function getDoors(): int {
return $this->doors;
}
}
Attempting to modify a readonly property after construction throws an Error. The language enforces what was previously just a convention. The boilerplate also shrinks - property declaration and constructor assignment collapse into one line with constructor property promotion.
Do Readonly Properties Replace Getters?
Since readonly properties cannot change, you could make them public and skip the getter entirely:
<?php
class Car {
public function __construct(
public readonly Engine $engine,
public readonly int $doors,
) {}
}
This is valid and widely used. However, there is a practical trade-off: interfaces cannot declare properties, only methods. If your architecture relies on interfaces to define the shape of entities (as in Keestash, where IUser defines what a user must expose), removing getters means you can no longer enforce that contract through an interface.
My preferred approach: use public readonly properties for the implementation, but keep getter methods on the interface. The readonly property satisfies the getter's contract from outside, and the language guarantees the value won't change.
PHP 8.2: Readonly Classes
PHP 8.2 extended the concept to entire classes. Declaring a class readonly makes every property readonly implicitly - no need to annotate each one:
<?php
// PHP 8.1 - annotate each property
class Car {
public function __construct(
private readonly Engine $engine,
private readonly Color $color,
private readonly Brand $brand,
private readonly FuelType $fuelType,
) {}
}
// PHP 8.2 - declare the class once
readonly class Car {
public function __construct(
private Engine $engine,
private Color $color,
private Brand $brand,
private FuelType $fuelType,
) {}
}
Beyond syntax, readonly classes have additional constraints enforced by PHP:
- Typed properties only - all properties must have a declared type.
- No static properties - static state would break the immutability guarantee.
- No default property values - every property must be set explicitly at construction time.
- No inheritance from non-readonly classes - a non-readonly subclass cannot extend a readonly parent, because it could introduce mutable properties.
- No dynamic properties - you cannot add properties to a readonly class instance at runtime.
Readonly Classes vs Readonly Properties: When to Use Which
| Scenario | Recommendation |
|---|---|
| DTO / Value Object with 3+ properties | readonly class - cleaner syntax, stronger guarantees |
| Entity that implements an interface with getters | readonly properties + getter methods |
| Single immutable property on a larger class | readonly property |
| Class that needs to extend a non-readonly parent | readonly properties only - readonly class is not possible |
Updating Immutable Objects
The one practical friction point: when you need to produce a modified copy of a readonly object, you cannot mutate in place - you create a new instance. PHP 8.4 added with-style cloning for this via constructor property promotion, but prior to that the pattern is manual:
<?php
readonly class Money {
public function __construct(
public int $amount,
public string $currency,
) {}
}
$price = new Money(100, 'EUR');
$discounted = new Money($price->amount - 10, $price->currency);
For typical DTOs this is fine. For objects mutated in tight loops, profile before optimising.
Conclusion
Readonly properties (8.1) and readonly classes (8.2) bring proper language-level immutability to PHP. They replace the "private property + getter" convention with an enforceable guarantee, reduce boilerplate, and make objects safer to share across an application.
The practical recommendation: use readonly class for DTOs and value objects, readonly properties when you need to extend a non-readonly parent or mix readonly and mutable state, and keep getter methods where interfaces require them.