Readonly Classes in PHP 8.2: Making Whole Classes Immutable
AI generated
8.4
PHP · Readonly Classes · PHP 8.2
Readonly Classes in PHP 8.2
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.

11 min read Value Objects PHP 8.2 - 8.4

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.

11. FAQ: Readonly Classes

1What is the difference between readonly properties and readonly classes?
Individual readonly properties mark each field separately, a readonly class applies immutability automatically to every typed property in the class at once.
2Since which PHP version do readonly classes exist?
Readonly classes were introduced in PHP 8.2, while individual readonly properties have existed since PHP 8.1.
3Can a readonly class have dynamic properties?
No, dynamic properties are fully forbidden in readonly classes, even the AllowDynamicProperties attribute does not change that.
4Do all properties in a readonly class need a type?
Yes, every property must declare a type, an untyped property in a readonly class causes a fatal error.
5Must a child class of a readonly class also be readonly?
Yes, PHP requires every child class of a readonly class to also be declared readonly explicitly, otherwise the declaration fails.
6Can a readonly property still change during cloning?
Since PHP 8.3 a single reassignment is allowed inside the __clone method, outside of that every property stays fixed after its first assignment.
7Are readonly classes suitable for Doctrine entities?
Usually not, since Doctrine sets properties after the fact through reflection during lazy loading, which a readonly class prevents.
8How do you migrate existing value objects to readonly classes?
Type every property first, replace setters with wither methods that return a new instance, and only then add the readonly keyword.
9Does a readonly class bring measurable performance gains?
Possible OPcache optimizations are a side effect, the real benefit lies in correctness and safer sharing of instances.
10How can you check at runtime whether a class is readonly?
Through ReflectionClass::isReadOnly(), which reports the readonly status of a class at runtime, useful for generic serializers.