PHP 8.2 — Readonly classes
Concept
PHP 8.2 extended the readonly keyword from individual properties to entire classes. When you declare readonly class Foo, every property declared inside it is implicitly readonly — you do not need to annotate each one. Additionally, a readonly class can only declare typed properties (untyped properties in a readonly class cause a fatal error), and properties can only be initialized once — any subsequent write throws an Error.
The principal use case is immutable value objects and data transfer objects. In domain-driven design, a Money value object or an Address record should never change after construction. Before PHP 8.2, enforcing immutability required either making each property readonly individually or using complex constructor patterns with no public setters. readonly class collapses this to a single keyword.
A subtle but important restriction: dynamic properties are forbidden on readonly classes, and readonly classes cannot be extended by non-readonly classes. A readonly class can extend another readonly class, but the child cannot relax the immutability guarantee. This makes inheritance hierarchies of value objects natural while preventing subclasses from sneaking in mutable state.
Cloning a readonly object deserves special attention. As of PHP 8.3, clone with partial property override was introduced, but in PHP 8.2 cloning a readonly object and reassigning any property inside __clone is a fatal error. The standard pattern is to use a with() method that calls clone and returns a new instance after modifying the clone through Reflection or by using construction — which is awkward. PHP 8.4's clone improvements address this more cleanly.
| Feature | Per-property readonly | readonly class |
|---|---|---|
| Requires typing each property | Yes | No — class-level |
| Untyped properties allowed | Yes (but not readonly) | No |
| Implicit readonly on all props | No | Yes |
| Dynamic properties allowed | Subject to PHP version | No, always banned |
| Extendable by mutable class | Yes | No |
Code Example
<?php
declare(strict_types=1);
readonly class Money
{
public function __construct(
public int $amount, // in cents
public string $currency, // ISO 4217
) {}
public function add(Money $other): static
{
if ($this->currency !== $other->currency) {
throw new \InvalidArgumentException(
"Cannot add {$this->currency} to {$other->currency}"
);
}
return new static($this->amount + $other->amount, $this->currency);
}
public function multiply(float $factor): static
{
return new static((int) round($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 sprintf('%s %.2f', $this->currency, $this->amount / 100);
}
}
// Readonly class extending another readonly class
readonly class Price extends Money
{
public function __construct(
int $amount,
string $currency,
public float $vatRate, // VAT fraction, e.g. 0.20
) {
parent::__construct($amount, $currency);
}
public function withVat(): Money
{
return $this->multiply(1 + $this->vatRate);
}
}
$price = new Price(1000, 'EUR', 0.20); // €10.00 excl. VAT
$gross = $price->withVat(); // €12.00 incl. VAT
echo $price->format(); // EUR 10.00
echo $gross->format(); // EUR 12.00
// Immutability is enforced:
try {
$price->amount = 500; // Error: Cannot modify readonly property
} catch (\Error $e) {
echo $e->getMessage();
}
// Attempting to set a dynamic property fails too
try {
$price->discount = 0.10; // Error: Cannot create dynamic property
} catch (\Error $e) {
echo $e->getMessage();
}Interview Q&A
Q: What does readonly class enforce beyond making every property readonly?
Beyond the per-property readonly guarantee (write-once, on initialization), readonly class additionally bans dynamic properties entirely, requires every property to be typed, and prevents the class from being extended by a non-readonly class. Together these constraints form a strict immutability contract: the object's shape is fixed (no new properties), the types are explicit, and no subclass can introduce mutable state. This makes readonly class appropriate for value objects that must be compared by value and treated as constants throughout their lifetime.
Q: How do you produce a "modified copy" of a readonly object in PHP 8.2?
PHP 8.2 does not provide a native clone with syntax (that arrived in PHP 8.4). The idiomatic workaround is a with() factory method that accepts named arguments and delegates to the constructor: return new static($this->amount + $delta, $this->currency). Another pattern uses clone followed by Reflection to bypass the readonly guard inside __clone, but this is fragile and considered an anti-pattern. The cleanest approach is designing value objects so that transformation methods always return a new instance via the constructor, which is exactly what immutability demands.
Q: Can a readonly class implement interfaces with settable-property contracts, and what are the gotchas?
A readonly class can implement any interface — interfaces do not declare properties, only methods, so there is no direct conflict. The gotcha arises when a class uses #[Attribute] or frameworks that hydrate objects by setting properties after construction (e.g., some serializers, Doctrine DBAL). Those tools rely on setting properties post-instantiation, which readonly forbids. In practice, readonly class requires constructor-based hydration: every value must be passed to __construct. Laravel's Eloquent, for instance, is incompatible with readonly class for model properties because Eloquent hydrates via property assignment. You would use readonly class for DTOs and value objects, not for ActiveRecord models.