Making whole classes immutable
Since PHP 8.1, individual properties can be declared readonly. Since PHP 8.2, an entire class can be made immutable in one step. The difference is more than syntactic sugar: a readonly class is a class level design decision, with its own rules for inheritance, dynamic properties, and migrating existing value objects.
Table of Contents
- 1. Beyond single readonly properties
- 2. Syntax: readonly before the class declaration
- 3. What changes automatically: every property becomes readonly
- 4. No dynamic properties are possible anymore
- 5. Inheritance rules for readonly classes
- 6. Migrating existing value objects to readonly classes
- 7. Cloning and the with pattern in readonly classes
- 8. When readonly classes do not fit
- 9. Tooling and reflection for readonly classes
- 10. Summary
- 11. FAQ
1. Beyond single readonly properties
Individual readonly properties are their own topic with their own rules for initialization and cloning, and this article is not about those basics. It is about the layer above: the readonly class as a whole. Instead of marking every property with the readonly keyword one by one, PHP 8.2 lets you declare an entire class readonly, and the interpreter applies the rule automatically to every typed property.
That shift from property level to class level is not just less typing. A readonly class is a promise to everyone who works with it: every instance is fully frozen after construction, with no exceptions for individual fields. That makes the class a clear signal for value objects and DTOs, where partial mutability was never the intent in the first place.
2. Syntax: readonly before the class declaration
The syntax places the readonly keyword directly before class, optionally combined with final. Inside the class, no property needs its own readonly keyword anymore, the class level modifier applies to every typed property, including ones declared through constructor property promotion.
One requirement is enforced along the way: every property in the class must be typed, because readonly without a type is a parse error regardless of whether it is declared on a property or on a class. Anyone turning an existing class with untyped properties into a readonly class has to type everything first.
declare(strict_types=1);
final readonly class Money
{
public function __construct(
private int $amountInCents,
private string $currency,
) {
}
public function add(Money $other): self
{
if ($this->currency !== $other->currency) {
throw new InvalidArgumentException('Currency mismatch');
}
// Returns a new instance, both operands stay untouched
return new self($this->amountInCents + $other->amountInCents, $this->currency);
}
}
3. What changes automatically: every property becomes readonly
Once a class is declared readonly, the keyword applies implicitly to every property, whether it comes from a classic property block or from constructor property promotion. Adding readonly to an individual property afterward is not wrong, just redundant, since the interpreter enforces it either way.
Trying to declare an untyped property inside a readonly class aborts with a fatal error at compile time, not at runtime on first access. That is an important difference from a single readonly property, where the rest of the class is free to stay untyped and mutable.
// Fatal error: Readonly property Config::$options must have type
readonly class Config
{
public $options; // untyped, not allowed in a readonly class
}
4. No dynamic properties are possible anymore
A readonly class forbids dynamic properties entirely, even if the class also carries the AllowDynamicProperties attribute. The reason lies in the nature of dynamic properties: they get created ad hoc at runtime and would never pass through the readonly check that happens at declaration time.
For legacy code that relies on freely setting arbitrary properties, for example as a stand in for an associative array, migrating to readonly classes draws a hard line. That code has to move to explicit, typed properties or a separate array field before the switch.
readonly class Options
{
public function __construct(public string $mode)
{
}
}
$options = new Options('strict');
$options->extra = 'value'; // Error: Cannot create dynamic property Options::$extra
5. Inheritance rules for readonly classes
If a class extends a readonly class, the child class must also be declared readonly explicitly. PHP does not allow a silent relaxation where a subclass suddenly introduces mutable properties while the parent stays immutable.
Leaving out the keyword in the child class aborts the declaration with a fatal error before any instance is ever created. That differs noticeably from inheritance with individual readonly properties, where a child class is free to add its own new readonly or mutable properties, as long as it leaves the parent's inherited properties untouched.
readonly class Point
{
public function __construct(public float $x, public float $y)
{
}
}
// Fatal error: Class Point3D must be declared readonly to extend readonly class Point
class Point3D extends Point
{
public function __construct(float $x, float $y, public float $z)
{
parent::__construct($x, $y);
}
}
6. Migrating existing value objects to readonly classes
Migrating a value object that has grown over time is worth a systematic pass over every method that mutates state. Setter methods that overwrite a property directly need to go entirely, since a single remaining assignment path is enough to trigger an error on first call.
In practice, the migration usually follows three steps: type every property completely first, replace setters with wither methods that return a new instance, and only then add the readonly keyword to the class. That order surfaces, while you are still migrating, which callers still rely on mutating state.
// Before: mutable value object with a setter
final class DateRange
{
private DateTimeImmutable $start;
public function setStart(DateTimeImmutable $start): void
{
$this->start = $start; // mutates existing instance
}
}
// After: readonly class, wither instead of setter
final readonly class DateRange
{
public function __construct(
public DateTimeImmutable $start,
public DateTimeImmutable $end,
) {
}
public function withStart(DateTimeImmutable $start): self
{
return new self($start, $this->end); // returns a new instance
}
}
7. Cloning and the with pattern in readonly classes
Since a readonly class does not allow reassignment after the fact, a wither pattern for derived states is unavoidable. Every with method constructs a completely new instance internally through the regular constructor instead of changing existing values.
Since PHP 8.3, readonly properties may be reassigned once inside the __clone method, which can save constructor calls for very large objects with many properties. For most value objects with only a handful of fields, an explicit call through new self remains the clearer and less error prone approach.
8. When readonly classes do not fit
Entities whose state changes over the lifetime of a request or session, such as a shopping cart or an object managed by an ORM, are poor candidates for readonly classes. Doctrine proxies, for example, set properties after the fact through reflection during lazy loading, something a readonly class prevents outright.
The builder pattern, where an object is assembled step by step across multiple method calls before final use, also clashes with readonly classes, because every intermediate step would need to produce a new instance instead of mutating the builder. For those cases, a deliberately mutable builder class that produces an immutable result object at the end remains the more practical solution.
9. Tooling and reflection for readonly classes
Static analysis tools like PHPStan and Psalm catch readonly violations at analysis time and flag an attempted second write as an error long before the code ever runs. That holds for readonly classes exactly as it does for individual readonly properties, since both are backed by the same language feature.
At runtime, the readonly status of a class can be queried through ReflectionClass::isReadOnly(), which is useful for generic serializers or hydrators that need to distinguish mutable from immutable objects. Any OPcache performance gains are a side effect here, not the primary reason to reach for readonly classes.
$reflection = new ReflectionClass(Money::class);
if ($reflection->isReadOnly()) {
// Safe to share this instance across coroutines without defensive copying
echo 'Money is immutable';
}
| Aspect | Single readonly property | readonly class (PHP 8.2+) | Mutable class |
|---|---|---|---|
| Declaration effort | Per property, with readonly |
Once at the class level for all properties | No extra keyword needed |
| Dynamic properties | Still allowed for non-readonly properties | Fully forbidden, even with the attribute | Allowed unless disabled |
| Inheritance | Child class free to choose per property | Child class must also be readonly | No restriction |
| Untyped properties | Not allowed for readonly properties themselves | Not allowed anywhere in the class | Allowed |
| Migration effort | Low, selective per field | Moderate, affects the whole class | No migration needed |
| Typical use case | Mixed classes with a few fixed fields | Pure value objects and DTOs | Entities with a mutable lifecycle |
Mironsoft
PHP modernization, code quality, and legacy refactoring
Grown PHP code nobody wants to touch anymore?
We modernize PHP codebases to current language standards, introduce static analysis and coding standards, and refactor legacy code step by step without endangering live operations.
Legacy Refactoring
Modernize grown PHP code in a structured, low-risk way.
Establishing Code Quality
Anchor PHPStan, coding standards, and CI checks sustainably in the team.
Version Upgrades
Plan and execute PHP major version upgrades safely, without downtime.
10. Summary
Readonly Classes
Core idea
readonly before the class makes every typed property immutable automatically instead of marking each one.
Constraint
No dynamic properties, no untyped fields, and child classes must also be declared readonly.
Use case
Pure value objects and DTOs benefit the most, entities with an ORM lifecycle usually do not.
Migration
Type everything first, replace setters with wither methods, then add readonly last.