Enum и readonly-классы в PHP: value objects без лишнего кода
Enum и readonly-свойства убрали большую часть ритуального кода, которым раньше приходилось защищать value object от случайного изменения. Разбираем практические примеры, сериализацию и работу с базой данных.

До появления enum и readonly-свойств value object в PHP защищали вручную: приватные поля, конструктор с проверками, отсутствие сеттеров, иногда магический метод __set, который бросал исключение при попытке изменить объект снаружи. Это работало, но требовало внимательности на каждом новом value object, и забытая проверка легко оставляла объект изменяемым там, где он должен был быть неизменяемым по дизайну.
Readonly-свойства: неизменяемость, которую проверяет сам язык
Модификатор readonly, добавленный в PHP 8.1 и ставший удобнее с конструктор-промоушеном, запрещает изменять свойство после его инициализации в конструкторе на уровне языка, а не на уровне дисциплины разработчика. Попытка присвоить значение readonly-свойству снаружи или даже внутри класса после инициализации вызывает ошибку на этапе выполнения, а не просто нарушает договорённость команды.
final class Money
{
public function __construct(
public readonly int $amountInCents,
public readonly string $currency,
) {
if ($amountInCents < 0) {
throw new \InvalidArgumentException('Сумма не может быть отрицательной');
}
}
public function add(Money $other): self
{
if ($this->currency !== $other->currency) {
throw new \InvalidArgumentException('Нельзя складывать разные валюты');
}
return new self($this->amountInCents + $other->amountInCents, $this->currency);
}
}Метод add() не изменяет текущий объект, а возвращает новый экземпляр с результатом сложения. Это стандартный паттерн для value object: любая операция, которая логически «меняет» значение, на самом деле создаёт новый объект, а исходный остаётся нетронутым. Такой подход снимает целый класс ошибок, связанных со случайным общим владением объектом в нескольких местах кода, когда правка в одном месте неожиданно влияет на другое.
Enum вместо строковых констант
До появления enum в PHP 8.1 статусы и перечисляемые значения обычно описывали строковыми или числовыми константами класса, и ничто в языке не мешало передать произвольную строку туда, где ожидался один из допустимых статусов. Backed enum решает эту проблему на уровне типов: значение либо принадлежит перечислению, либо код не компилируется.
enum OrderStatus: string
{
case New = 'new';
case Paid = 'paid';
case Shipped = 'shipped';
case Cancelled = 'cancelled';
public function isFinal(): bool
{
return match ($this) {
self::Shipped, self::Cancelled => true,
self::New, self::Paid => false,
};
}
}
$status = OrderStatus::from('paid');
if ($status->isFinal()) {
// ...
}Метод isFinal() показывает ключевое преимущество enum над строковыми константами: перечисление может содержать собственное поведение, а не только список допустимых значений. Логика «какие статусы считаются финальными» живёт рядом с самими статусами, а не в отдельном сервисе с массивом строк, который легко забыть обновить при добавлении нового статуса.
| Задача | Enum | Readonly-класс |
|---|---|---|
| Закрытый набор значений (статус, роль, тип) | Подходит напрямую | Не подходит, это не перечисление |
| Составное значение с несколькими полями (деньги, адрес) | Не подходит, enum не хранит произвольные поля экземпляра | Подходит напрямую |
| Поведение, зависящее от конкретного значения | Метод внутри case через match | Обычный метод объекта |
Сериализация в JSON
Backed enum сериализуется в JSON через json_encode() как его базовое значение, строку или число, без дополнительного кода, если сам enum попадает в сериализуемую структуру напрямую. Readonly-классы сериализуются как обычные объекты PHP, то есть все публичные свойства превращаются в поля JSON-объекта автоматически.
Например, массив с полями status типа OrderStatus::Paid и address в виде readonly-объекта ShippingAddress с полями city и postalCode превращается в json_encode() в обычный JSON-объект: статус сериализуется как строка "paid", а адрес как вложенный объект с теми же полями, без единой строчки дополнительного кода.
Обратный путь, из JSON обратно в объекты, не происходит автоматически: json_decode() вернёт обычный массив или stdClass, и восстановление типизированного enum или readonly-объекта требует явного кода, например статического метода fromArray() на классе, который вызывает OrderStatus::from() для строки статуса. Библиотеки сериализации вроде Symfony Serializer умеют делать это автоматически при правильной настройке, но без них придётся писать маппинг руками.
::from(). Если во внешних данных окажется значение, не входящее в перечисление, ::from() выбросит ValueError сразу в точке разбора, а не где-то глубже в бизнес-логике.Работа с базой данных
При сохранении readonly value object в базу данных обычно нет смысла пытаться хранить его как единый объект на уровне ORM: проще разложить составной value object на отдельные столбцы, например amount_in_cents и currency для класса Money, и собирать объект обратно в репозитории при чтении строки. Это требует небольшого ручного маппинга, но избегает зависимости доменного класса от конкретной ORM.
На практике метод репозитория hydrate(array $row): Money собирает объект Money из колонок amount_in_cents и currency строки результата запроса, а соседний метод statusFromRow() просто вызывает OrderStatus::from($row['status']) для восстановления enum. Оба метода короткие именно потому, что вся проверка инвариантов уже выполнена внутри конструктора Money и внутри самого enum.
Для enum большинство современных ORM, включая Doctrine начиная с версии 2.11, поддерживают backed enum как тип колонки напрямую, без ручного маппинга, если столбец в базе хранит строку или число, совпадающее с базовым значением enum. Стоит явно проверить версию используемой ORM перед тем, как рассчитывать на эту поддержку, потому что в более старых версиях придётся писать собственный тип колонки.
Когда readonly-класс избыточен
Не каждая структура данных выигрывает от readonly и собственного класса. Для одноразового DTO внутри одного метода, который живёт несколько строк и никуда не передаётся дальше, обычный ассоциативный массив или анонимный объект может быть проще и не требует отдельного файла класса. Readonly-класс оправдан, когда значение передаётся между несколькими слоями приложения, и важно гарантировать, что оно не будет случайно изменено по пути.
- Enum подходит для закрытого набора значений с возможным поведением, зависящим от конкретного значения
- Readonly-класс подходит для составных неизменяемых значений, которые передаются между слоями приложения
- Простой короткоживущий набор данных внутри одного метода не всегда требует отдельного класса
- Сериализация enum в JSON работает автоматически, обратное восстановление из JSON требует явного кода
Enum и readonly-классы не заменяют архитектурные решения, они убирают ручную работу, которая раньше была нужна для тех же самых гарантий. Там, где пять лет назад требовалось написать приватное поле, геттер и проверку в конструкторе, теперь достаточно одной строки объявления, и это освобождает внимание разработчика для действительно сложных решений, а не для повторения одного и того же защитного кода в каждом новом value object.
Интерфейсы для enum
Enum в PHP может реализовывать интерфейсы, и это открывает способ описать общее поведение для нескольких перечислений без дублирования кода. Например, если в проекте одновременно есть OrderStatus и PaymentStatus, и оба должны уметь возвращать читаемую подпись для интерфейса пользователя, общий интерфейс HasLabel описывает это требование один раз, а каждый enum реализует его своим способом.
Интерфейс HasLabel с единственным методом label(): string описывает это требование один раз, а каждый enum, например OrderStatus или PaymentStatus, реализует его своим match-блоком, возвращая человекочитаемую подпись для своих значений. Код, который отображает статус на экране, может принимать параметр типа HasLabel и вызывать label(), не зная, с каким конкретно перечислением он работает. Это тот же принцип инверсии зависимостей, который применяется не к классам, а к enum: код, использующий подпись, зависит от интерфейса, а не от конкретного перечисления.
Клонирование и неизменяемость вложенных объектов
Важная тонкость readonly-классов: модификатор защищает само свойство от переприсваивания, но если свойство хранит объект, а не примитивное значение, внутренние данные этого вложенного объекта защищены только тогда, когда он сам сделан readonly или immutable по своей собственной природе. Readonly-свойство типа массив тоже требует внимания: нельзя переприсвоить сам массив, но для объектов внутри массива readonly ничего не гарантирует без их собственной неизменяемости.
Например, класс Invoice с единственным readonly-свойством items, массивом объектов LineItem, защищён от переприсвоения самого массива: $invoice->items = [] снаружи вызовет ошибку. Но если класс LineItem сам не readonly, его поля можно спокойно изменить изнутри каждого объекта в этом массиве, и внешнее readonly-свойство Invoice никак этому не помешает.
Практический вывод: если value object должен быть по-настоящему неизменяемым целиком, включая вложенные объекты и коллекции, каждый класс во вложенной структуре нужно делать readonly отдельно, а не рассчитывать, что readonly на внешнем классе автоматически распространяется вглубь. Это не недостаток языка, а последствие того, что readonly защищает конкретное свойство конкретного объекта, а не весь граф объектов, до которого можно дойти через это свойство.
Клонирование readonly-объектов с изменённым полем
Поскольку readonly-свойство нельзя изменить после инициализации, операция «изменить одно поле, остальные оставить как есть» требует создания нового объекта, а не правки существующего. PHP 8.3 добавил поддержку клонирования readonly-свойств внутри магического метода __clone, что упростило написание методов вроде withStatus(), которые возвращают копию объекта с одним изменённым значением, не переписывая вручную все остальные поля в новом конструкторе.
final class Order
{
public function __construct(
public readonly string $id,
public readonly OrderStatus $status,
public readonly int $total,
) {}
public function withStatus(OrderStatus $status): self
{
$clone = clone $this;
$clone->status = $status; // допустимо внутри __clone до PHP 8.3 было ошибкой
return $clone;
}
}Метод withStatus() возвращает новый объект с изменённым статусом, не трогая исходный, что сохраняет главное свойство value object: любое изменение порождает новое значение, а не модифицирует старое на месте. Такой паттерн особенно удобен, когда объект передаётся через несколько слоёв приложения и важно гарантировать, что ни один промежуточный слой случайно не изменит состояние, которое уже используется где-то ещё.
Частые вопросы
Чем readonly-свойство отличается от обычного private-свойства с геттером?
Readonly-свойство может быть публичным и доступным для чтения снаружи, но изменить его после инициализации в конструкторе нельзя даже изнутри самого класса. Это проверяется языком, а не договорённостью команды.
Можно ли хранить в enum дополнительные данные помимо базового значения?
Нет, backed enum хранит только одно базовое значение на каждый case. Для составных данных с несколькими полями нужен обычный readonly-класс, а не enum.
Как восстановить enum из JSON после json_decode?
Через статический метод OrderStatus::from() с нужной строкой или числом. Прямое приведение типов не работает, а from() дополнительно выбросит понятную ошибку, если значение не входит в перечисление.



