enforcing immutability at the language level
Mutable objects are one of the most common sources of hard-to-trace bugs: a value changes somewhere in the call graph, and nobody quite remembers where. Readonly Properties move this guarantee from convention to the language level, for individual properties since PHP 8.1, for entire classes since PHP 8.3, turning immutability into a property the interpreter itself enforces instead of merely documenting.
Table of Contents
- 1. Why Immutability at the Language Level Matters
- 2. Syntax and Basic Rules of readonly
- 3. Initialization: Only Once, Only From the Declaring Scope
- 4. Readonly Classes Since PHP 8.3
- 5. Clone Behavior on Readonly Objects
- 6. Immutable Value Objects and the with-Pattern
- 7. Readonly vs. const and final
- 8. Readonly and Inheritance
- 9. Common Mistakes and Pitfalls
- 10. Summary
- 11. FAQ
1. Why Immutability at the Language Level Matters
Mutable state is one of the most reliable ways to create bugs that are hard to reproduce. An object gets passed into a method, somewhere deep in the call graph a line of code changes a property, and the caller wonders hours later why its supposedly unchanged object suddenly carries different values. Before PHP 8.1, the only tools against this problem were conventions: private properties, a getter without a setter, a docblock saying "immutable, do not modify". All of that is a request to other developers, not a guarantee from the compiler. Readonly Properties change this fundamentally, because immutability is anchored directly in the language's type system.
The mistake that frequently happens without Readonly Properties: a value object like a monetary amount or a date range gets passed by reference through several layers of an application, and some layer mutates it "just briefly" before passing it on. This works fine until two code paths reference the same object and one of them expects a changed state that the other path never intended. Such bugs are notoriously hard to debug, because the fault can occur far away in time and space from the symptom.
Even outside concurrency in the classic sense, immutability pays off: an object whose state is fixed after construction is easier to understand, easier to test, and easier to trace through code, because reading a method no longer requires checking whether it mutates some property of a passed-in object. Readonly Properties make this promise explicit and mechanically verifiable, instead of leaving it to trust in the discipline of the development team.
2. Syntax and Basic Rules of readonly
The syntax of Readonly Properties is deliberately minimal: the keyword readonly is placed before the visibility modifier of a typed property, for example public readonly string $email;. A central restriction that is often overlooked: a readonly property must be typed, a readonly $value without a type declaration is a parse error. That is consistent with the general goal of the language to tightly couple type information with language guarantees.
A readonly property can be assigned a value exactly once; after that, every further write attempt is a runtime Error, not a silent no-op and not a warning. This applies regardless of whether the second write attempt comes from inside or outside the class. This is what fundamentally distinguishes readonly from a property merely protected by visibility: a private property can be reassigned any number of times inside the class, a readonly property cannot, not even from its own class, after the first assignment.
The error class name is deliberately specific: Error, not TypeError or ValueError, with a clear message like "Cannot modify readonly property Foo::$bar". This predictability matters when handling Readonly Properties in try/catch blocks: anyone programming defensively who wants to catch a re-assignment attempt catches Error specifically, not the generic Throwable base without further differentiation.
declare(strict_types=1);
final class EmailAddress
{
// A readonly property must be typed - untyped readonly is a parse error
public readonly string $value;
public function __construct(string $value)
{
if (!str_contains($value, '@')) {
throw new InvalidArgumentException('Invalid email address');
}
$this->value = $value; // first and only assignment
}
}
$email = new EmailAddress('dev@mironsoft.de');
echo $email->value; // dev@mironsoft.de
try {
$email->value = 'other@example.com'; // throws Error, even from outside
} catch (Error $e) {
echo $e->getMessage(); // Cannot modify readonly property EmailAddress::$value
}
3. Initialization: Only Once, Only From the Declaring Scope
The initialization rule for Readonly Properties is stricter than "assign only once": the first write must happen exclusively from the scope of the class that declared the property. Concretely, this means that even the constructor of a subclass cannot directly initialize an inherited readonly property of the parent class once it already holds a value, and that code outside the class may never be the first writer, not even to a still-uninitialized property.
If a typed property is read before it was initialized, readonly or not, PHP throws an Error with a message about uninitialized typed properties. With Readonly Properties, this state of "not yet initialized, but already declared" can genuinely occur, for example when a property is filled not in the constructor but in a method called later. That is allowed, as long as the assignment comes from the declaring scope and happens only once, but it demands care so that an object is not passed to other code in an unusable intermediate state.
Constructor property promotion is the usual and recommended way to initialize Readonly Properties, because declaration, typing, and first assignment coincide in a single line inside the constructor. That reduces the risk of accidentally leaving a property unfilled to a minimum, because the value is directly enforced as a constructor parameter and PHP performs the assignment automatically.
declare(strict_types=1);
final class Money
{
// Constructor property promotion combines declaration and first assignment
public function __construct(
public readonly int $amountInCents,
public readonly string $currency,
) {
if ($amountInCents < 0) {
throw new InvalidArgumentException('Amount cannot be negative');
}
}
public function add(Money $other): self
{
if ($this->currency !== $other->currency) {
throw new InvalidArgumentException('Currency mismatch');
}
// Cannot mutate $this - a new instance is returned instead
return new self($this->amountInCents + $other->amountInCents, $this->currency);
}
}
$price = new Money(1999, 'EUR');
$shipping = new Money(495, 'EUR');
$total = $price->add($shipping); // new Money instance, both originals untouched
4. Readonly Classes Since PHP 8.3
PHP 8.3 introduces readonly class to save you from repeating readonly in front of every single property. When a class is declared as a whole with readonly class Money { ... }, all declared properties of the class automatically count as Readonly Properties, without the keyword having to be repeated per property. This is a noticeable simplification especially for value objects with many fields, and it reduces visual noise in the code.
An important restriction: a readonly class may only contain typed properties, the same rule as for a single readonly property, just now enforced for the entire class. In addition, a readonly class cannot later be turned into a "partially mutable" class by explicitly marking a single property as non-readonly, that concept simply does not exist. Anyone who needs a mix of mutable and immutable properties must fall back to declaring readonly per individual property instead of making the whole class readonly.
A second important effect of readonly class: a subclass of a readonly class must itself be readonly. It is not possible to inherit from a readonly class and make the derived class mutable. This rule prevents the immutability guarantee from being silently undermined through inheritance, which would otherwise be a subtle breach of the expectations a caller places on the base type.
declare(strict_types=1);
// Every property in this class is implicitly readonly
readonly class DateRange
{
public function __construct(
public DateTimeImmutable $start,
public DateTimeImmutable $end,
) {
if ($start > $end) {
throw new InvalidArgumentException('Start must be before end');
}
}
public function containsDate(DateTimeImmutable $date): bool
{
return $date >= $this->start && $date <= $this->end;
}
public function durationInDays(): int
{
return $this->start->diff($this->end)->days;
}
}
$range = new DateRange(
new DateTimeImmutable('2026-01-01'),
new DateTimeImmutable('2026-01-31'),
);
echo $range->durationInDays(); // 30
5. Clone Behavior on Readonly Objects
A common misunderstanding about Readonly Properties is the assumption that an object can never be modified at all, not even through cloning. In fact, up to and including PHP 8.2, it was not allowed to reassign readonly properties inside a __clone() method, because cloning itself counted as "outside the original initialization scope". This restriction got in the way of many practical with-pattern implementations, because new values could only be produced through entirely new constructor calls, even if only a single property needed to change.
Since PHP 8.3, the language allows re-initializing Readonly Properties inside __clone(), as long as the assignment happens within the declaring class and the property is already initialized at that point. This opens up a clean way to change individual values inside a clone in a targeted manner, without having to run the whole constructor with all its validation again. The original object being cloned from remains completely untouched; only the new copy receives the changed values.
The important boundary: this capability applies exclusively to reassignment inside __clone() itself, not to arbitrary code outside the class after a clone operation. A caller who writes clone $object and then tries to set a property of the clone directly still gets the familiar Error. Control over mutation inside the clone stays entirely with the class itself, which is consistent with the core principle of Readonly Properties: changes are only possible from within the declaring scope, never from outside.
declare(strict_types=1);
final class Money
{
public function __construct(
public readonly int $amountInCents,
public readonly string $currency,
) {
}
// Since PHP 8.3: readonly properties may be reassigned inside __clone()
public function withAmount(int $newAmountInCents): self
{
$clone = clone $this;
// This reassignment is only legal inside __clone(), never from outside
return $clone;
}
public function __clone(): void
{
// Example: normalize or adjust a value during cloning if needed
}
}
6. Immutable Value Objects and the with-Pattern
Because Readonly Properties forbid any direct mutation after initialization, an immutable value object needs a different mechanism to express "changed" states: instead of altering an existing instance, a new instance is created with the desired values, while the original instance remains fully untouched. This pattern is known as the with-pattern, named after the common method convention withX(), such as withAmount() or withStatus().
Libraries like DateTimeImmutable established this pattern well before Readonly Properties existed: $date->modify('+1 day') does not change the original object but returns a new one. With readonly properties, the same behavior can be recreated consistently for custom value objects without relying on internal tricks, because the language core itself prevents a with method from accidentally returning $this instead of a copy and mutating it.
The practical advantage shows up especially in concurrent or distributed code, in queue consumers, or in situations where an object is passed to many places in the code: since Readonly Properties rule out any mutation, none of those places can unknowingly change the object for everyone else. Every code path that wants a change must explicitly request a new instance and explicitly pass it on, which makes data flows in the code considerably easier to follow than implicit mutation at some distant location.
declare(strict_types=1);
readonly class OrderLine
{
public function __construct(
public string $sku,
public int $quantity,
public int $unitPriceInCents,
) {
}
// "with" methods return a new instance instead of mutating the current one
public function withQuantity(int $quantity): self
{
return new self($this->sku, $quantity, $this->unitPriceInCents);
}
public function totalInCents(): int
{
return $this->quantity * $this->unitPriceInCents;
}
}
$line = new OrderLine('SKU-123', 2, 1999);
$updatedLine = $line->withQuantity(5);
echo $line->quantity; // 2 - the original instance is untouched
echo $updatedLine->quantity; // 5 - a distinct new instance
7. Readonly vs. const and final
A recurring misunderstanding is equating Readonly Properties with class constants or with final, even though all three solve different problems. A class constant is fixed at compile time, is bound to the class itself, not to an instance, and its value is identical across all instances. A readonly property, by contrast, is set at runtime, usually in the constructor, and can carry a different value for every instance. Anyone trying to model individual object data such as an email address or an order total with constants quickly hits a limit that Readonly Properties simply do not have.
The keyword final, in turn, concerns inheritance, not mutability: a final method cannot be overridden, a final class cannot be extended, but neither says anything about whether an instance property can be changed after construction. It is entirely common and sensible to combine final class with Readonly Properties, for example in value objects, because both properties support the same goal: predictable behavior that is not secretly changed, once at the inheritance-hierarchy level, once at the object-state level.
In practice, all three mechanisms complement each other: const for values that are independent of any concrete instance and known at compile time, such as a tax rate or a currency-rounding factor. Readonly Properties for instance-specific data that should stay stable after creation. final for the class or method itself, when extensibility should be deliberately excluded. Anyone who keeps these three tools clearly apart avoids confusion about which language feature addresses which concrete problem.
| Mechanism | Binding | When It's Fixed | Solves Which Problem |
|---|---|---|---|
| const | Class, shared across all instances | Compile time | Fixed, class-wide values |
| Readonly Properties | Instance, individual value | Runtime, usually in the constructor | Immutable instance state |
| final | Class or method | Declaration | Preventing inheritance/overriding |
| private without readonly | Instance, mutable | Any number of times at runtime | Encapsulation without immutability |
8. Readonly and Inheritance
Inheritance and Readonly Properties have several special rules that must be considered when modeling class hierarchies. A subclass cannot redeclare an inherited readonly property as non-readonly to lift the restriction, that would break the contract the base class makes with every consumer. Likewise, a subclass cannot redeclare an already-typed readonly property of the base class with an incompatible type, the same covariance rules as for ordinary typed properties still apply unchanged.
Special care is needed with inherited constructors: if a subclass defines its own constructor and wants to additionally initialize a readonly property of the parent class, that initialization must happen via a call to parent::__construct(), if the property was declared in the parent class, because only the declaring scope may perform the first assignment. A direct access like $this->parentProperty = $value; in the subclass's constructor is not allowed for a readonly property declared in the parent class, even if the property is visible as protected.
For abstract classes and interfaces: an interface itself cannot declare properties, but an abstract class can pre-declare Readonly Properties that concrete subclasses must then fill in their own constructors. This pattern is useful for enforcing a shared, immutable base structure while leaving the concrete initialization logic to the respective subclasses, for example in a family of event classes that all carry a readonly timestamp but have different payload types.
declare(strict_types=1);
abstract class DomainEvent
{
public function __construct(
public readonly DateTimeImmutable $occurredAt,
) {
}
}
final class OrderPlaced extends DomainEvent
{
public function __construct(
public readonly string $orderId,
DateTimeImmutable $occurredAt,
) {
// Must delegate initialization of the parent's readonly property
parent::__construct($occurredAt);
}
}
$event = new OrderPlaced('ORD-4711', new DateTimeImmutable());
echo $event->occurredAt->format('Y-m-d H:i:s');
9. Common Mistakes and Pitfalls
The most common pitfall with Readonly Properties is assuming that an object is fully immutable as soon as its properties are readonly. That only applies to the reference itself, not to the referenced content: if a readonly property is an array or a mutable object, readonly only forbids overwriting the property with a completely new array or object. The content of a readonly array can still be modified by addressing individual elements directly, for example $order->items[] = $newItem;, which in many cases opens an unintended gap in the intended immutability.
A second pitfall concerns reflection: the PHP reflection API can still write to readonly properties using ReflectionProperty::setValue() with explicit removal of access restrictions, provided the calling code has the corresponding permissions. Readonly Properties are therefore a strong, but not an absolute, security guarantee against every form of manipulation; they reliably protect against accidental mutation in ordinary application code, but not against deliberate circumvention via reflection or serialization internals.
A third, more subtle pitfall concerns unserialize() and similar mechanisms: objects reconstructed from a serialized string or from var_export() output can, under certain circumstances, find ways to set readonly properties outside the normal constructor flow, depending on the PHP version and the serialization format used. Anyone using Readonly Properties in objects that get serialized and restored, for example in session data or message-queue payloads, should test the concrete behavior of the PHP version in use rather than blindly relying on the constructor guarantee.
10. Summary
Readonly Properties move immutability from a convention to a guarantee enforced by the language core. The syntax is minimal, the keyword readonly before a typed property, but the rules behind it are precise: exactly one assignment, exclusively from the declaring scope, with a clear Error on every further write attempt. Readonly Classes since PHP 8.3 make this guarantee usable for entire classes with a single keyword instead of per property, and the extended clone behavior since PHP 8.3 allows controlled re-initialization inside __clone() without softening the guarantee from outside.
The with-pattern is the natural companion of Readonly Properties: instead of mutating an object, every change produces a new instance while the original stays untouched. It remains important to be aware of the limits: readonly protects a property's reference, not automatically the content of mutable arrays or objects inside it, and reflection can bypass the guarantee under certain conditions. Anyone who knows these limits and applies Readonly Properties deliberately to value objects, DTOs, and domain events gains code that holds fewer surprises when read and whose state remains traceable at any point.
Readonly Properties at a Glance
Syntax and Rule
readonly before a typed property, exactly one assignment only from the declaring scope, otherwise an Error.
Readonly Classes (8.3)
readonly class makes all properties readonly; subclasses must also be readonly.
with-Pattern
Changes produce new instances instead of mutating, the original stays untouched.
Know the Limits
Protects the reference, not the content of mutable arrays. Reflection can bypass the guarantee.
11. FAQ: Readonly Properties in PHP
1What are Readonly Properties and since which version do they exist?
2Must a readonly property always be typed?
3What is a Readonly Class?
4Can I change a readonly property inside __clone()?
5What is the difference between readonly and const?
6Does readonly also protect array content?
7What is the with-pattern?
8Can a subclass initialize an inherited readonly property itself?
9Can reflection bypass readonly properties?
10Readonly class or individual readonly properties?
Mironsoft
PHP code reviews, modernization, and immutability patterns
Is mutable state causing hard-to-trace bugs?
We audit existing PHP codebases for mutable value objects and introduce Readonly Properties, Readonly Classes, and the with-pattern where it matters, so object state stays predictable and data flows stay traceable.
Code Review
Analysis of existing value objects and DTOs for Readonly Properties migration potential
Modernization
Introducing Readonly Classes and the with-pattern into existing domain models
Immutability Strategy
Clear separation of value objects, entities, and mutable state across the entire application core