Новое в журнале. Маленькие pull request: почему их быстрее ревьюят и как резать задачу
PHP и бэкенд

Enum и readonly-классы в PHP: value objects без лишнего кода

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

5 мая 20269 мин чтенияПрактикам
Enum и readonly-классы в PHP: value objects без лишнего кода
2 инструментаenum и readonly в одной задаче
0 сеттеровв правильном 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: любая операция, которая логически «меняет» значение, на самом деле создаёт новый объект, а исходный остаётся нетронутым. Такой подход снимает целый класс ошибок, связанных со случайным общим владением объектом в нескольких местах кода, когда правка в одном месте неожиданно влияет на другое.

Главная мысльReadonly-класс не экономит строки кода ради самой экономии, он делает невозможным состояние объекта, которое никогда не должно было существовать.

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 над строковыми константами: перечисление может содержать собственное поведение, а не только список допустимых значений. Логика «какие статусы считаются финальными» живёт рядом с самими статусами, а не в отдельном сервисе с массивом строк, который легко забыть обновить при добавлении нового статуса.

ЗадачаEnumReadonly-класс
Закрытый набор значений (статус, роль, тип)Подходит напрямуюНе подходит, это не перечисление
Составное значение с несколькими полями (деньги, адрес)Не подходит, 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 умеют делать это автоматически при правильной настройке, но без них придётся писать маппинг руками.

Частая ошибка: пытаться восстановить enum из JSON через прямое приведение типов вместо ::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() дополнительно выбросит понятную ошибку, если значение не входит в перечисление.

Код, который не стыдно передать

Начните с принципов, а дальше двигайтесь по рубрикам. Каждый разбор заканчивается чек-листом.

Случайный разбор