Readonly и перечисления в PHP (Readonly classes and enums)
Статус заказа становится типом, а деньги - неизменяемым значением: опечатку в статусе и тихую правку суммы ловит язык, а не код-ревью.
Проблема
До PHP 8.1 заказ обычно жил в массиве, а статусы - в строковых константах:
final class Order
{
public const STATUS_NEW = 'new';
public const STATUS_PAID = 'paid';
}
$order = ['id' => 'A-17', 'status' => Order::STATUS_NEW, 'total' => 129000, 'currency' => 'RUB'];
$order['status'] = 'payed'; // typo, nobody notices
$order['total'] -= 500; // anyone can change the sum
function ship(array $order): void {} // which keys are inside? read the codeТип string пропускает любую строку, поэтому 'payed' доезжает до базы. Правила переходов (new можно оплатить, shipped уже нельзя отменить) расходятся по if в контроллерах. Сумма и валюта - два независимых ключа: можно сложить рубли с долларами, и никто не возразит. Массив передаётся по значению, но после $order['total'] -= 500 никто не скажет, где сумма поменялась.
Решение
Три средства языка, каждое закрывает свою дыру:
- Backed enum
OrderStatus: string- закрытый список статусов. Строка из базы или запроса превращается в статус черезOrderStatus::from()(бросаетValueError) илиtryFrom()(возвращаетnull). Внутри кода ходят уже не строки, а значения типа, и PHPStan видит опечаткуOrderStatus::Payedдо запуска. - Методы и интерфейс у enum.
label()иnext()живут прямо вOrderStatus, аHasLabelпозволяет передавать статус туда, где ждут "что-то с подписью". Правила переходов - в одном месте, а не в каждом контроллере. matchпо enum - выражение, а не оператор: оно возвращает значение и бросаетUnhandledMatchError, если забыли случай. Добавили статус - PHPStan покажет каждыйmatch, который его не покрывает.readonly-классыMoneyиOrder: свойства задаются один раз в конструкторе, после этого любая запись -Error. Изменение состояния - это новый объект:moveTo()возвращает новыйOrder, старый остаётся как был.
Структура
Правила переходов записаны в OrderStatus::next() через match: из New можно в Paid или Cancelled, из Paid - в Shipped или Cancelled, финальные статусы никуда не ведут. Order::moveTo() спрашивает статус и при запрете бросает InvalidStatusTransition. Money хранит копейки в int и не складывает разные валюты.
Как это собирается
Строки живут только на границе. Репозиторий читает строку таблицы и сразу превращает status и currency в enum через from(): если в базе мусор, падение будет здесь, с понятной ошибкой. Дальше по коду ходит Order с типизированными полями. Вебхук платёжки присылает статус строкой, обработчик берёт tryFrom(), потому что чужие данные бывают любыми и неизвестный статус - не авария, а повод ответить "не знаю такой". Переход проверяет сам статус, обработчик только ловит InvalidStatusTransition и пишет отказ.
Сценарий целиком с выводом: пример на PHP.
Как это в других языках
Go. Перечислений нет: обычно type OrderStatus string с константами через const, но это всё тот же string - OrderStatus("payed") компилируется. Исчерпывающий switch проверяют линтером (exhaustive), а неизменяемость держится на соглашении: неэкспортируемые поля и методы с получателем-значением, которые возвращают копию.
Python. enum.StrEnum даёт закрытый список и OrderStatus("payed") с ValueError, методы у членов enum пишутся так же, как в PHP. Неизменяемость - @dataclass(frozen=True), а исчерпывающий match проверяет mypy или pyright через assert_never. В рантайме Python никого не остановит, всё держится на анализаторе.
TypeScript. Вместо enum чаще берут объединение литералов type OrderStatus = "new" | "paid" | "shipped" | "cancelled": строка из JSON проверяется руками, зато внутри кода опечатка - ошибка компиляции. readonly и Readonly<T> работают только при компиляции, в рантайме объект можно поменять, если не вызвать Object.freeze. Исчерпывающую проверку даёт never, подробнее в теме о размеченных объединениях.
Запуск: make test T=languages/php/01-readonly-and-enums
Тесты проверяют таблицу переходов, то, что moveTo() не трогает исходный заказ, отказ from() на неизвестной строке, запрет сложения разных валют и Error при записи в readonly-свойство.
Плюсы и минусы
- ✅ Опечатка в статусе - ошибка анализатора или
ValueErrorна границе, а не мусор в базе. - ✅ Правила статуса живут в самом enum,
matchподсвечивает все места, которые надо обновить при новом статусе. - ✅
readonly-объект можно смело передавать куда угодно: его никто не поменяет у вас за спиной. - ❌ Каждое изменение - новый объект и "конструктор-копировщик" вроде
moveTo(); при десятке полей это многословно (в PHP 8.5 помогаетcloneс новыми значениями). - ❌ Enum нельзя расширить из другого модуля: если список статусов настраивается пользователем, он должен жить в данных, а не в коде.
- ❌ Кэши и сериализация требуют внимания: enum и
readonlyнадо явно превращать в строки и числа на границе.
Когда не применять
- Список значений меняется без релиза (категории, теги, статусы из админки) - это справочник в базе, а не enum.
- Объект по смыслу изменяемый и живёт долго (сессия, построитель запроса, накопитель):
readonlyзаставит пересоздавать его на каждом шаге без пользы. - Простой DTO на один вызов, где нет инвариантов:
readonlyне повредит, но и проверять нечего.
Связанные темы
- Состояние (State) - когда у каждого статуса своё поведение, а не только список переходов
- Тактический DDD -
Moneyкак объект-значение - Размеченные объединения в TypeScript - та же исчерпывающая проверка средствами компилятора