readonly classes (PHP 8.2+) — immutable value objects
Concept
Readonly classes, introduced in PHP 8.2, make every property of a class implicitly readonly. Before PHP 8.2, you had to mark each property individually with readonly, which was verbose and easy to miss. A class declared with readonly automatically applies the readonly modifier to all declared properties, enforces that every property must be typed, and prevents dynamic property creation on instances.
Under the hood, a readonly property can only be initialized once — either in the constructor or via a promoted constructor parameter. Any attempt to reassign the property after initialization throws an Error with message "Cannot modify readonly property". This constraint is enforced at the Zend VM level, not by a runtime check in userland, so there is no performance cost compared to a mutable class.
Readonly classes are the canonical way to implement Value Objects in PHP. A Value Object represents a domain concept (Money, Email, Coordinates) whose identity is its value, not a database ID. Two Money objects both representing "100 USD" should be equal regardless of which instance they are. Immutability guarantees that once you construct the object it can never become inconsistent — you can safely pass it across method boundaries, cache it, serialize it, and put it in a collection without defensive copies.
One important limitation: readonly classes cannot have non-typed properties, and they cannot be declared abstract readonly in PHP 8.2 (that restriction was lifted in PHP 8.3 for abstract readonly classes). Also, static properties are not made readonly by the class-level keyword — only instance properties are affected. Child classes of a readonly class must also be declared readonly.
In Laravel, readonly classes appear in form request DTOs, in value objects passed to Eloquent model factories, and in data returned from service layer methods. The spatie/laravel-data package uses them extensively: its Data base class leverages constructor property promotion and readonly semantics to create immutable data transfer objects that can be validated, cast, and serialized with minimal boilerplate.
Code Example
<?php
declare(strict_types=1);
// PHP 8.2+ readonly class — all properties are implicitly readonly
readonly class Money
{
public function __construct(
public int $amount, // minor units (cents)
public string $currency,
) {
if ($amount < 0) {
throw new \InvalidArgumentException("Amount cannot be negative: {$amount}");
}
if (strlen($currency) !== 3) {
throw new \InvalidArgumentException("Currency must be ISO 4217: {$currency}");
}
}
public function add(Money $other): static
{
if ($this->currency !== $other->currency) {
throw new \DomainException("Cannot add {$this->currency} and {$other->currency}");
}
// Must return a new instance — cannot mutate $this
return new static($this->amount + $other->amount, $this->currency);
}
public function multiply(int $factor): static
{
return new static($this->amount * $factor, $this->currency);
}
public function equals(Money $other): bool
{
return $this->amount === $other->amount && $this->currency === $other->currency;
}
public function format(): string
{
return number_format($this->amount / 100, 2) . ' ' . $this->currency;
}
}
$price = new Money(1999, 'USD'); // $19.99
$tax = new Money(160, 'USD'); // $1.60
$total = $price->add($tax);
echo $total->format(); // 21.59 USD
// This would throw: cannot modify readonly property
// $price->amount = 999;
// Child class must also be readonly
readonly class DiscountedMoney extends Money
{
public function __construct(
int $amount,
string $currency,
public float $discountPct,
) {
parent::__construct($amount, $currency);
}
public function afterDiscount(): static
{
$discounted = (int) round($this->amount * (1 - $this->discountPct / 100));
return new static($discounted, $this->currency, $this->discountPct);
}
}
$item = new DiscountedMoney(5000, 'EUR', 20.0);
$afterSale = $item->afterDiscount();
echo $afterSale->format(); // 40.00 EURInterview Q&A
Q: What does the readonly keyword on a class do that marking each property readonly individually does not?
A class-level readonly declaration applies the modifier to all typed instance properties automatically, so you cannot accidentally leave one mutable. It also prevents dynamic property creation on instances — any attempt to set an undeclared property throws an Error, which #[AllowDynamicProperties] would otherwise permit. Additionally it serves as a self-documenting contract: anyone reading the class signature knows immediately that every instance is immutable, without scanning every property declaration. The trade-off is that the entire class becomes readonly — you cannot mix mutable and readonly properties, so the modifier is only appropriate when the full immutability invariant is desired.
Q: How do you "update" a readonly object if you cannot mutate it?
You create a new instance with the changed value — a pattern called a wither or with-method. Because readonly properties cannot be cloned-then-mutated with clone, you must call the constructor explicitly: return new static($newAmount, $this->currency). PHP 8.4 introduced clone with syntax that allows clone $obj with { amount: 500 }, which is the ergonomic solution. Before 8.4 the common approach is either explicit wither methods or using (new \ReflectionClass($this))->newInstanceWithoutConstructor() followed by property initialization via Reflection — a trick spatie/laravel-data used internally.
Q: Can a readonly class extend a non-readonly class?
No. PHP enforces that a readonly class can only extend another readonly class. This is a deliberate constraint: if a child were readonly but a parent were mutable, the parent could expose setter methods or mutable properties that undermine the child's immutability guarantee. The rule ensures the immutability contract applies uniformly across the inheritance chain. In practice this means readonly classes work best as leaf nodes or in an all-readonly hierarchy. If you need to extend a mutable base (such as Laravel's Model), mark individual properties readonly instead of using the class-level keyword.