handbookНаписать

languages/php/01-readonly-and-enums/README.md · тема · 1 язык · 12 тестов

Readonly и перечисления в PHP (Readonly classes and enums) ​

Статус заказа становится типом, а деньги - неизменяемым значением: опечатку в статусе и тихую правку суммы ловит язык, а не код-ревью.

Проблема ​

До PHP 8.1 заказ обычно жил в массиве, а статусы - в строковых константах:

php
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, старый остаётся как был.

Структура ​

«interface»

HasLabel

+label() : string

«enumeration»

OrderStatus

New

Paid

Shipped

Cancelled

+label() : string

+next() : list

+canBecome(next) : bool

«enumeration»

Currency

Rub

Usd

«readonly»

Money

+amount: int

+currency: Currency

+add(other) : Money

+format() : string

«readonly»

Order

+id: string

+total: Money

+status: OrderStatus

+moveTo(next) : 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 не повредит, но и проверять нечего.

Связанные темы ​

© 2014-2026 Тимур ТурдыевСайт и справочник работают на VPS от AdminVPS