Readonly Properties and Classes: Immutability, Promotion, and Cloning

Understand PHP 8.1's readonly properties and PHP 8.2's readonly classes — exactly what immutability guarantee they provide (and don't), how they interact with constructor property promotion, typed properties, inheritance, and cloning, and when to reach for them instead of getter-only conventions.

PHP has always let you simulate immutability by convention — declaring a private property with only a getter and no setter — but nothing enforced that convention from inside the class itself; any method on the class, including ones added later by a careless refactor, could still reassign that 'private' property at will. PHP 8.1 closed this gap with real, engine-enforced readonly properties: once a readonly property is initialized, any further attempt to write to it — from anywhere, including inside the declaring class — throws an Error. PHP 8.2 extended this to entire readonly classes, making every property readonly at once without repeating the keyword on each one. Together with constructor property promotion (PHP 8.0), this is what enables the compact, immutable value-object style now standard in modern PHP.

Declaring Readonly Properties — PHP 8.1+

A property is made readonly by adding the readonly modifier before its type: class Point { public readonly float $x; public readonly float $y; public function __construct(float $x, float $y) { $this->x = $x; $this->y = $y; } }. A readonly property MUST have a declared type — public readonly $x; without a type is a compile error, because PHP needs a type to know the property's 'uninitialized' state precisely.

The critical rule is: a readonly property can be written to exactly once, and only from within the scope of the class that declares it (typically the constructor, though any method in the declaring class works). After that single initialization, ANY further write — from inside the class or outside it — throws Error: Cannot modify readonly property ClassName::$propertyName. This is enforced by the engine itself, not by convention, which is the entire point: it closes off the same category of accidental-mutation bug that const closes off for simple variables, but for object properties.

Readonly is not the same as 'set once, anywhere, at any time' — the write must happen from inside the declaring class's own scope. $point->x = 5; from outside the class always fails, even on a freshly-constructed, not-yet-initialized object, because external code is never in the declaring class's scope. This is what makes readonly properties safe to expose as public — external code can read them freely but has no path to mutate them, full stop.

• public readonly float $x; — requires an explicit type; untyped readonly properties are a compile error. • Can be assigned exactly once, only from inside the declaring class's own scope (usually the constructor). • Any write after initialization, or any write attempted from outside the class, throws Error: Cannot modify readonly property. • Safe to make public — external code can read but has no path to write, since external code is never 'inside the declaring class' scope.

Constructor Property Promotion + Readonly — The Idiomatic Combo

Constructor property promotion (PHP 8.0) lets you declare and initialize a property directly in the constructor's parameter list, eliminating the old boilerplate of declaring a property, then a matching constructor parameter, then a manual $this->x = $x; assignment for every single field. Combined with readonly, this collapses an entire immutable value object into a one-line constructor: class Point { public function __construct(public readonly float $x, public readonly float $y) {} } declares two readonly properties AND assigns them from the constructor arguments, with zero body statements needed.

This combination — promoted, readonly, typed constructor parameters — is now the standard idiom for value objects, DTOs (data transfer objects), and domain entities across modern PHP code: class Money { public function __construct(public readonly int $amountInCents, public readonly string $currency) {} }. It reads almost like a struct/record definition in other languages, and behaves like one: once constructed, a Money instance cannot be mutated by anything, anywhere.

Each promoted parameter can independently choose visibility (public, protected, private) and whether it's readonly, so a class can mix promoted and non-promoted, readonly and mutable properties as needed: public function __construct(public readonly string $id, private array $tags = []) — $id is a public, immutable identity, while $tags remains a private, mutable list managed by the class's own methods.

• public function __construct(public readonly float $x) {} — one line declares, types, promotes, and locks the property, all at once. • This is the standard idiom for immutable value objects / DTOs in modern PHP — no separate property declarations or manual assignments needed. • Each promoted parameter picks its own visibility and readonly status independently — mixing mutable and readonly fields in one constructor is fine. • Promoted readonly properties still follow all normal readonly rules — one-time initialization, write-protected from outside the class.

Readonly Classes — PHP 8.2+

PHP 8.2 added the readonly class modifier, which makes EVERY declared property of the class readonly automatically, without writing readonly on each one individually: readonly class Point { public function __construct(public float $x, public float $y) {} } is exactly equivalent to marking both $x and $y readonly by hand. This removes the last bit of repetition for classes where every single property should be immutable, which is common for pure value objects.

A readonly class also implicitly forbids declaring any non-typed property and forbids static properties from being readonly-by-class-default in a way that would be meaningless — in practice, if every property in your value object should be immutable and typed (which is the normal case for a DTO), readonly class is strictly less to type than adding readonly to every individual property, with identical enforcement.

If a class is declared readonly, every class that extends it must also be declared readonly — PHP does not allow a mutable subclass to extend a readonly parent (since that would let the subclass reintroduce mutable state, undermining the parent's immutability guarantee). This is a meaningful inheritance constraint to plan for: reach for readonly class when you're confident the entire hierarchy should be immutable, not just the base.

• readonly class Point { public function __construct(public float $x, public float $y) {} } — every property becomes readonly automatically. • Equivalent to writing readonly before every individual typed property, but with none of the repetition. • Available from PHP 8.2 (readonly PROPERTIES were 8.1; readonly CLASSES came a version later). • A subclass of a readonly class must also be declared readonly — you cannot introduce mutable state further down the hierarchy.

Immutability Semantics: What Readonly Does and Does NOT Guarantee

Readonly enforces that the PROPERTY BINDING cannot be reassigned — it does not make the value it holds deeply immutable. If a readonly property holds an array, that array's own contents can be mutated in place... actually not quite: PHP treats $this->items[] = 'x'; on a readonly array property as a modification of the property itself (arrays are value types in PHP, so appending to it constitutes reassigning the property), and this correctly throws just like a direct reassignment would. The real gap is with OBJECT-valued readonly properties: if public readonly DateTime $createdAt holds a mutable DateTime object, $obj->createdAt->modify('+1 day'); calls a mutating method ON that object — the readonly property still points at the same object, so this is allowed and DOES mutate visible state, even though the property binding itself was never reassigned.

This is why, for genuinely immutable value objects, you should prefer immutable value types for readonly properties where they exist — DateTimeImmutable instead of DateTime being the canonical example — because readonly only protects the outer binding, not the mutability of whatever object sits inside it. A readonly property holding a mutable object gives a false sense of safety unless you're careful about this distinction.

Readonly properties also interact with clone. Cloning an object with clone $obj copies it shallowly, and as of PHP 8.1, a readonly property CAN be re-initialized exactly once inside the special __clone() magic method, even though it was already initialized on the original object — this is specifically to support patterns like 'return a modified copy' (a withX()-style method that clones, then updates one readonly field on the clone before returning it). As of PHP 8.3, __clone() gained more flexibility here, but the PHP 8.1 baseline rule to remember is: __clone() is a special, permitted second initialization point for a readonly property, but only within __clone() itself — you still cannot reassign it after the clone completes.

• Readonly protects the PROPERTY BINDING, not deep immutability — a readonly property holding a mutable object (e.g. DateTime) can still have that object's own state mutated through it. • Prefer inherently immutable types (DateTimeImmutable over DateTime) for readonly properties whenever the domain allows it. • Reassigning a readonly array property entirely ($this->arr = [...]) or appending to it ($this->arr[] = x) are both writes and both throw after initialization. • __clone() is a special permitted re-initialization point for readonly properties on the new clone — enabling 'with a field changed' copy patterns.

The 'Wither' Pattern: Immutable Updates via Clone

Because readonly properties can't be reassigned after construction, the idiomatic way to produce 'a copy of this object with one field changed' is a method — conventionally prefixed with — that clones the current instance and reassigns just that one property on the clone, inside __clone() or directly after cloning depending on PHP version conventions. A simple, widely portable pattern: public function withCurrency(string $currency): static { $clone = clone $this; $clone->currency = $currency; return $clone; } — this works because, per the previous section, a readonly property can be re-initialized exactly once specifically during the cloning process.

This 'wither' pattern (named by analogy to Java/other ecosystems' withX() methods) is how immutable value objects support 'updates' without ever actually mutating the original: $price = new Money(500, 'USD'); $eurPrice = $price->withCurrency('EUR'); leaves $price completely untouched and produces a brand-new Money instance. This is a deliberate design trade-off: you give up in-place mutation entirely, in exchange for objects that can be passed around, cached, and shared without ever worrying that some other part of the code silently changed them underneath you — a property that becomes increasingly valuable as codebases and team sizes grow.

• The 'wither' pattern: withX(...) clones the object and updates one field on the clone, leaving the original untouched. • Relies on readonly's special allowance for one re-initialization of a readonly property, scoped to the cloning operation. • Produces true immutable-update semantics: the original instance is guaranteed never to change after construction. • This trade-off (no in-place mutation, ever) is precisely why readonly value objects are considered safer to share/cache/pass around than mutable ones.

Quick Reference: Readonly Properties, Classes, and Promotion

Readonly properties must have a declared type — no readonly without a type. readonly class (8.2+) applies readonly to every property; all subclasses must also be readonly. The wither pattern (withX()) is the standard way to produce a modified copy of an immutable object.

// Readonly property, PHP 8.1+ (must be typed)
class Point {
    public function __construct(
        public readonly float $x,
        public readonly float $y,
    ) {}
}

// Readonly class, PHP 8.2+ (every property becomes readonly automatically)
readonly class Money {
    public function __construct(
        public int $amountInCents,
        public string $currency,
    ) {}

    public function withCurrency(string $currency): static
    {
        $clone = clone $this;
        $clone->currency = $currency; // allowed: one-time re-init during clone
        return $clone;
    }
}

$price = new Money(500, 'USD');
$eur   = $price->withCurrency('EUR');

echo $price->currency; // "USD" — original untouched
echo $eur->currency;   // "EUR" — new instance

$price->amountInCents = 999; // Error: Cannot modify readonly property Money::$amountInCents

Code Examples

The constructor's assignment is the property's one and only permitted write. Both tryChangeEmail() (an internal second write) and the external assignment attempt fail with the same Error — readonly enforces single-initialization, not merely 'no external writes'. Expected result: Cannot modify readonly property User::$email

$this->email = $newEmail; // throws — already initialized readonly class makes both lat and lng readonly without repeating the keyword. movedBy() never mutates $origin — it clones it and reassigns the readonly properties on the CLONE only, which is specifically permitted once per clone, then returns that new instance. Expected result: origin: (0.0, 0.0) moved: (1.5, -2.0)

$clone->lat += $dLat; // one-time re-init, allowed only on the freshly cloned instance readonly only stops $log->recordedAt from being reassigned to a DIFFERENT DateTime object — it does nothing to stop calling a mutating method ON the DateTime object that's already there. Switching to DateTimeImmutable closes the gap, because ITS modify() returns a new instance instead of mutating in place, so there is no mutating method to accidentally call through the readonly reference. Expected result: 2027-01-01

$log->recordedAt->modify('+1 year'); // mutates the object; the readonly property still points at it

Common mistake: Assuming 'readonly' means the ENTIRE object graph is frozen. It only freezes which object/value the property points to — it says nothing about that object's own mutability.

PHP enforces that once a class is readonly, every subclass in its hierarchy must be readonly too — this prevents a subclass from quietly reintroducing mutable state that would undermine callers who rely on instances of the base type being fully immutable. Expected result: circle: r=2.5 readonly class Circle extends Shape {... } // subclass must repeat readonly

class User
{
    public readonly string $email;

    public function __construct(string $email)
    {
        $this->email = $email; // OK: first write, inside declaring class
    }

    public function tryChangeEmail(string $newEmail): void
    {
        $this->email = $newEmail; // throws! even from inside the class, it's a SECOND write
    }
}

$user = new User('[email protected]');
echo $user->email; // "[email protected]" — reading is always fine

try {
    $user->email = '[email protected]'; // throws from outside too
} catch (Error $e) {
    echo $e->getMessage();
}
readonly class Coordinates
{
    public function __construct(
        public float $lat,
        public float $lng,
    ) {}

    public function movedBy(float $dLat, float $dLng): self
    {
        $clone = clone $this;
        $clone->lat += $dLat;   // permitted: one-time re-init during clone
        $clone->lng += $dLng;
        return $clone;
    }
}

$origin = new Coordinates(0.0, 0.0);
$moved  = $origin->movedBy(1.5, -2.0);

printf("origin: (%.1f, %.1f)\n", $origin->lat, $origin->lng);
printf("moved:  (%.1f, %.1f)\n", $moved->lat, $moved->lng);
class EventLog
{
    public function __construct(
        public readonly DateTime $recordedAt, // trap: DateTime is mutable
    ) {}
}

$log = new EventLog(new DateTime('2026-01-01'));
$log->recordedAt->modify('+1 year'); // allowed! mutates the DateTime object in place
echo $log->recordedAt->format('Y-m-d'); // "2027-01-01" -- "immutable" property changed!

// Fix: use DateTimeImmutable instead
class SafeEventLog
{
    public function __construct(
        public readonly DateTimeImmutable $recordedAt,
    ) {}
}

$safeLog = new SafeEventLog(new DateTimeImmutable('2026-01-01'));
// $safeLog->recordedAt->modify('+1 year'); // returns a NEW DateTimeImmutable, doesn't mutate
echo $safeLog->recordedAt->format('Y-m-d'); // still "2026-01-01"
readonly class Shape
{
    public function __construct(public string $name) {}
}

// This is a compile-time error: a non-readonly class cannot extend a readonly class
// class Circle extends Shape {
//     public float $radius = 1.0; // mutable property in a subclass of readonly Shape — not allowed
// }

// Correct: the subclass must also be declared readonly
readonly class Circle extends Shape
{
    public function __construct(string $name, public float $radius)
    {
        parent::__construct($name);
    }
}

$c = new Circle('circle', 2.5);
echo "{$c->name}: r={$c->radius}";

What readonly protects vs what it doesn't

readonly locks which object/value a property points to, not the internal mutability of that object — pick inherently immutable types to close the gap.

$order->total   (readonly float)
    [ PROTECTED ]  reassigning $order->total after construction -> throws

  $order->createdAt   (readonly DateTime  <-- mutable object!)
    [ BINDING PROTECTED, CONTENTS NOT ]
    $order->createdAt = new DateTime(...);   -> throws (rebinding blocked)
    $order->createdAt->modify('+1 day');     -> ALLOWED (mutates the object itself)

  Fix: use an immutable value type inside the readonly property
  $order->createdAt   (readonly DateTimeImmutable)
    $order->createdAt->modify('+1 day');     -> returns a NEW object, original untouched

Important Rules

Readonly properties must be typed — declare a concrete type, never leave it untyped.

A readonly property can be written exactly once, only from inside the declaring class's own scope — usually the constructor.

Prefer combining readonly with constructor property promotion for value objects/DTOs — it's the standard modern idiom.

Use readonly class (8.2+) when every property in the class should be immutable, to avoid repeating readonly on each one — but remember every subclass must also be readonly.

For object-valued readonly properties, prefer inherently immutable types (DateTimeImmutable, other value objects) — readonly does not stop mutation through a mutable object's own methods.

Use the clone-based 'wither' pattern (withX()) to produce updated copies of immutable objects instead of trying to mutate them in place.

Where this shows up in production PHP

Readonly properties and classes are now the default choice for DTOs (API request/response objects), domain value objects (Money, EmailAddress, Coordinates-style types), and event objects in event-driven architectures — anywhere an object represents 'a fact that happened' or 'a value' rather than 'something with ongoing mutable state'. Frameworks like Symfony use readonly extensively in their newer DTO and message classes, and PHPUnit-based test suites benefit heavily from readonly fixtures that can be shared across tests without one test's mutation leaking into another. The shift from mutable getter/setter-style objects to readonly value objects is widely considered one of the most impactful modern-PHP style changes for reducing an entire class of hard-to-trace state bugs.

class Point { public readonly $x; } // no type declared class Point { public readonly float $x; }

Readonly properties require an explicit type declaration — PHP rejects an untyped readonly property at compile time, because the engine needs the type to represent the property's distinct 'uninitialized' state correctly.

class Config { public readonly array $flags = []; } // default value at declaration class Config { public readonly array $flags; public function __construct(array $flags = []) { $this->flags = $flags; } }

A readonly property cannot have a default value at its declaration — that would count as an initialization outside the constructor, before any real value is known, and readonly properties can only be initialized once, from inside the class's own code, typically the constructor body or a promoted parameter.

public readonly DateTime $updatedAt; // assuming this makes timestamps fully immutable public readonly DateTimeImmutable $updatedAt;

readonly only prevents the PROPERTY from being reassigned to point at a different object — it does nothing to stop code from calling DateTime's own mutating methods (like modify()) on the object the property already points to. DateTimeImmutable closes this gap because its methods return new instances instead of mutating.

What is required for a property to be declared readonly in PHP 8.1+? A. It must be private B. It must have an explicit type declaration C. It must have a default value D. It must be static

B. It must have an explicit type declaration — A readonly property must have a declared type — PHP rejects an untyped readonly property with a compile error. Visibility can be public, protected, or private; readonly is independent of visibility, and a default value at declaration is actually disallowed, not required.

A readonly property is assigned once in the constructor. What happens if a DIFFERENT method in the SAME class later tries to assign it again? A. It succeeds, because it's still inside the declaring class's scope B. It throws an Error, because the property has already been initialized once, regardless of where the second write comes from C. It succeeds only if that method is also called from the constructor D. It silently does nothing

B. It throws an Error, because the property has already been initialized once, regardless of where the second write comes from — Readonly enforces single initialization, not merely 'writes only from inside the class.' Once a readonly property has been assigned once, ANY further assignment — even from another method in the very same declaring class — throws Error: Cannot modify readonly property.

A class has public readonly DateTime $createdAt;. Which statement is true? A. $obj->createdAt = new DateTime(); after construction throws, but $obj->createdAt->modify('+1 day'); is allowed and mutates the DateTime in place B. Both reassigning the property and calling ->modify() on it are blocked by readonly C. Neither is blocked — DateTime properties are exempt from readonly enforcement D. readonly automatically converts DateTime to DateTimeImmutable behind the scenes

A. $obj->createdAt = new DateTime(); after construction throws, but $obj->createdAt->modify('+1 day'); is allowed and mutates the DateTime in place — readonly only protects the property BINDING (which object it points to), not the internal mutability of the object it holds. DateTime's modify() mutates the object in place and does not reassign the property, so readonly does not block it — this is why DateTimeImmutable is recommended instead for genuinely frozen timestamps.

What is the relationship between a readonly class and its subclasses in PHP 8.2+? A. Subclasses are automatically readonly too, with no extra keyword needed B. Subclasses may freely choose to be mutable or readonly, independent of the parent C. A subclass of a readonly class must also be explicitly declared readonly D. A readonly class cannot be extended at all, under any circumstances

C. A subclass of a readonly class must also be explicitly declared readonly — PHP requires every subclass of a readonly class to itself be declared readonly — this is enforced specifically to prevent a subclass from reintroducing mutable properties that would undermine the immutability guarantee callers rely on for the base type.

Why does the 'wither' pattern (e.g. withCurrency()) work at all, given that readonly properties can only be assigned once? A. withX() methods are a special language construct exempt from readonly checks B. PHP specifically permits a readonly property to be re-initialized exactly once during __clone()/the cloning process, on the new clone only C. readonly properties can always be reassigned an unlimited number of times as long as the new value has the same type D. The wither pattern uses reflection to bypass readonly enforcement, which is a documented, supported technique

B. PHP specifically permits a readonly property to be re-initialized exactly once during __clone()/the cloning process, on the new clone only — PHP 8.1 carved out a specific allowance: within the cloning process, a readonly property may be assigned once more on the freshly-cloned object, even though it was already initialized on the original. This is precisely what makes the clone-then-reassign-one-field wither pattern legal, while any other post-construction reassignment still throws.

Define a readonly class Temperature (PHP 8.2+) with a promoted constructor property float $celsius. Add a method toFahrenheit(): float that computes and returns the Fahrenheit value (celsius * 9/5 + 32) WITHOUT mutating the object. Then add a wither method withCelsius(float $celsius): self that returns a new Temperature with a different celsius value, leaving the original untouched, using clone.

readonly class Temperature { public function __construct(public float $celsius) {} public function toFahrenheit(): float { return $this->celsius * 9 / 5 + 32; } public function withCelsius(float $celsius): self { $clone = clone $this; $clone->celsius = $celsius; return $clone; } } $boiling = new Temperature(100); echo $boiling->toFahrenheit(); // 212 $freezing = $boiling->withCelsius(0); echo $boiling->celsius; // 100 — original untouched echo $freezing->celsius; // 0 toFahrenheit() just computes and returns a value — it doesn't touch any property. withCelsius() should clone $this, reassign celsius on the clone (permitted once, during cloning), and return the clone.