Модифікатор readonly зʼявився в PHP 8.1 і застосовується до типізованої властивості екземпляра. Правило одне: у таку властивість можна записати значення рівно один раз і лише з коду класу, який її оголосив. Тому найчастіша форма — просування властивостей у конструкторі: public function __construct(public readonly int $amount) {}. Значення за замовчуванням заборонене (public readonly int $x = 5; не скомпілюється), тип обовʼязковий, static readonly не існує. Спроба присвоїти вдруге або ззовні дає Error: Cannot modify readonly property Money::$amount, а unset() — Error: Cannot unset readonly property.
Важливо не плутати readonly з const і з «незмінним обʼєктом». const — константа класу, спільна для всіх екземплярів і відома на етапі компіляції; readonly — значення конкретного обʼєкта, обчислене в рантаймі під час створення. І захист тут поверхневий: заборонено перезаписати саму властивість, а не те, що всередині. Масив у readonly-властивості змінити не можна ($this->items[] = $x — це модифікація властивості), а от у вкладеного обʼєкта цілком можна викликати мутуючий метод. Глибока незмінність досягається лише тим, що всередину кладуть скаляри або теж незмінні обʼєкти.
PHP 8.2 додав readonly class: усі властивості екземпляра стають readonly автоматично, нетипізовані й статичні властивості заборонені, динамічні властивості теж (#[AllowDynamicProperties] на такий клас поставити не можна), а нащадок readonly-класу зобовʼязаний бути readonly. Це майже готове визначення value object: readonly class OrderId { public function __construct(public string $value) {} }.
Найболючіше місце readonly — зміна значення в копії. Сам clone працював завжди, але в PHP 8.1–8.2 навіть __clone() не міг перезаписати readonly-властивість, тому глибоке копіювання доводилося емулювати конструктором. PHP 8.3 це виправив: під час __clone() readonly-властивості вважаються неініціалізованими й приймають один новий запис. PHP 8.5 пішов далі й дав синтаксис clone $obj with { amount: 50 }: нові значення застосовуються одразу після копіювання і до виклику __clone(), а перевірки видимості й readonly виконуються за скоупом, у якому стоїть вираз — тобто readonly-поле так само можна змінити лише зсередини класу. Ручні withX()-методи від цього не зникають, але їхнє тіло стискається до одного рядка.
Практичний критерій вибору такий. readonly замінює приватний сеттер (і зв’язку «private-властивість плюс геттер») там, де значення задається під час створення й більше ніколи не змінюється: ідентифікатори, гроші, DTO вхідного запиту, налаштування сервісу. Якщо ж значення має оновлюватися пізніше або обчислюватися ліниво, readonly лише заважає — жодного кешу чи memoize всередині такого обʼєкта не зробити. Для випадку «читають усі, пише лише клас, але не один раз» у PHP 8.4 є асиметрична видимість public private(set) int $x, а для обчислюваних значень — property hooks.
final class Money
{
public function __construct(
public readonly int $amount, // типізована, без default, пишеться раз
public readonly string $currency,
) {}
public function withAmount(int $amount): self
{
// PHP 8.5: копія зі зміненим полем; readonly перезаписується
// лише зі скоупу класу, який його оголосив
return clone $this with { amount: $amount };
// до 8.5: return new self($amount, $this->currency);
}
}
$price = new Money(100, 'UAH');
echo $price->amount; // читати можна звідусіль
// $price->amount = 200; // Error: Cannot modify readonly property
// unset($price->amount); // Error: Cannot unset readonly property
// PHP 8.2: усі властивості класу readonly автоматично
readonly class OrderId
{
public function __construct(public string $value) {}
}
final class Order
{
public function __construct(public readonly Money $total) {}
public function __clone(): void
{
// PHP 8.1–8.2: Error; з 8.3 readonly можна перезаписати в __clone
$this->total = clone $this->total;
}
}
Скажіть, що readonly — це не «незмінний обʼєкт», а «властивість, у яку можна записати один раз зі скоупу класу», і одразу назвіть шлях зміни: новий екземпляр, а з PHP 8.5 — `clone $this with { ... }`.