Сайт использует сookies для хранения данных. Продолжая использовать сайт, вы даёте согласие на работу с этими файлами.

ОК
🌐
Клиент-сервер
Опубликовано:
19.10.2026
•
Обновлено:
19.10.2026

Enum в PHP на практике: статусы заказов, backed enums и безопасная работа с БД

Данил Мануйлов

До появления PHP 8.1 статусы сущностей описывали набором строковых или целочисленных констант внутри классов. Такой подход регулярно приводил к скрытым багам: функции принимали произвольные строки, опечатка в значении 'padi' вместо 'paid' обнаруживалась только в рантайме, а switch без блока default молча пропускал новые состояния.

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


Pure vs Backed Enums: разница и выбор архитектуры

В PHP существует два типа перечислений: чистые (Pure) и типизированные (Backed).

Pure Enums (чистые перечисления)

Чистые перечисления представляют собой изолированные объекты-синглтоны. У их вариантов (cases) нет скалярного эквивалента в виде строки или числа.

declare(strict_types=1);

namespace App\Domain\Order;

enum DeliveryPriority
{
    case Low;
    case Medium;
    case High;
}

Кейсы чистых перечислений содержат только имя ($priority->name). Их удобно использовать для внутренней логики предметной области (Domain-Driven Design), когда состояние живет исключительно в оперативной памяти и не передается напрямую в SQL-запрос или сетевой протокол.

Backed Enums (типизированные перечисления)

Для сохранения в базу данных или сериализации в JSON чистый enum не подходит: у него нет нативного скаляра. Для этих целей используются Backed Enums. Они привязываются строго к одному из двух типов: string или int.

declare(strict_types=1);

namespace App\Domain\Order;

enum OrderStatus: string
{
    case New = 'new';
    case PendingPayment = 'pending_payment';
    case Paid = 'paid';
    case Shipped = 'shipped';
    case Cancelled = 'cancelled';
}

Правила и ограничения Backed Enums:

  1. Тип указывается явно через двоеточие после имени: : string или : int. Другие типы (float, bool, array) запрещены языком.
  2. Каждый кейс обязан иметь уникальное скалярное значение (backing value). Дубликаты вызовут фатальную ошибку компиляции.
  3. Автоматической инкрементации значений нет: даже для int каждое число нужно прописывать вручную.
  4. Значение доступно через свойство value, имя — через name.

Для статусов заказов предпочтительнее выбирать строковый тип (: string). Строковые идентификаторы читаемы в журналах аудита, дампах баз данных и не ломаются, если порядок кейсов в коде изменится.


Восстановление статуса из внешних данных: from() и tryFrom()

При получении данных из внешних источников (HTTP-запрос, вебхук платежной системы, строка из базы данных) скалярное значение необходимо преобразовать обратно в экземпляр Enum. Для этого PHP предоставляет два стандартных метода.

Метод from()

Метод OrderStatus::from($value) используется, когда входящие данные должны строго соответствовать известным вариантам перечисления.

declare(strict_types=1);

// Успешная гидрация
$status = OrderStatus::from('paid'); // OrderStatus::Paid

// Ошибка: неизвестное значение
try {
    $status = OrderStatus::from('unknown_status');
} catch (\ValueError $e) {
    // PHP выбросит ValueError, если значение не найдено
}

Если включен строгий режим (declare(strict_types=1);), то передача значения неверного типа (например, целочисленного 1 вместо строки '1') выбросит \TypeError еще до поиска совпадений.

Метод tryFrom()

Метод OrderStatus::tryFrom($value) работает мягче: если переданное значение отсутствует в списке кейсов, он возвращает null, не выбрасывая \ValueError.

declare(strict_types=1);

$rawStatus = $_POST['status'] ?? '';
$status = OrderStatus::tryFrom($rawStatus);

if ($status === null) {
    // Входящие данные не прошли валидацию
    throw new \InvalidArgumentException("Недопустимый статус заказа: {$rawStatus}");
}

Антипаттерн «молчаливый дефолт»

Распространенная ошибка при обработке недоверенного ввода — немедленная подстановка дефолтного статуса:

// ОПАСНО: маскирует ошибки и повреждает данные!
$status = OrderStatus::tryFrom($request->get('status')) ?? OrderStatus::New;

Если платежный шлюз прислал новый статус (например, 'partially_refunded'), который разработчики еще не успели добавить в код, заказ с этим кодом превратится в OrderStatus::New. В результате клиент получит товар заново или логика системы повторно запросит оплату.

Если значение не опознано:

  • Немедленно прерывайте транзакцию.
  • Логируйте исходную строку и контекст события.
  • Возвращайте клиенту или вебхуку ошибку валидации (HTTP 400 или 422).

Сопоставление и ветвление логики через match

Классическая конструкция switch использует нестрогое сравнение (==), требует операторов break на каждом шаге и допускает случайное проваливание логики (fall-through). Конструкция match, появившаяся в PHP 8.0, решает эти проблемы:

  • Сравнивает значения строго (===);
  • Возвращает результат как выражение;
  • Требует исчерпывающего сопоставления всех возможных вариантов (exhaustiveness check).
declare(strict_types=1);

namespace App\Domain\Order;

function getStatusNotification(OrderStatus $status): string
{
    return match ($status) {
        OrderStatus::New => 'Заказ оформлен и ожидает подтверждения.',
        OrderStatus::PendingPayment => 'Счет выставлен. Ожидаем оплату.',
        OrderStatus::Paid => 'Оплата получена. Собираем посылку.',
        OrderStatus::Shipped => 'Заказ передан в службу доставки.',
        OrderStatus::Cancelled => 'Заказ аннулирован.',
    };
}

Если в OrderStatus будет добавлен новый кейс Refunded, но разработчик забудет обновить функцию getStatusNotification, PHP при вызове выбросит фатальную ошибку \UnhandledMatchError. Это защищает систему от выполнения логики в неполном или неопределенном состоянии.

Инкапсуляция логики внутри Enum

Перечисления в PHP ведут себя как классы: они могут содержать методы, реализовывать интерфейсы и использовать трейты. Это позволяет инкапсулировать правила бизнес-домена непосредственно в перечислении.

declare(strict_types=1);

namespace App\Domain\Order;

enum OrderStatus: string
{
    case New = 'new';
    case PendingPayment = 'pending_payment';
    case Paid = 'paid';
    case Shipped = 'shipped';
    case Cancelled = 'cancelled';

    /**
     * Проверка: разрешено ли отменять заказ в этом статусе
     */
    public function canBeCancelled(): bool
    {
        return match ($this) {
            self::New, self::PendingPayment => true,
            self::Paid, self::Shipped, self::Cancelled => false,
        };
    }

    /**
     * Человекопонятное описание для админки
     */
    public function label(): string
    {
        return match ($this) {
            self::New => 'Новый',
            self::PendingPayment => 'Ожидает оплаты',
            self::Paid => 'Оплачен',
            self::Shipped => 'Отправлен',
            self::Cancelled => 'Отменен',
        };
    }
}

Сериализация и хранение в базе данных

Сохранение в SQL-таблицу

При передаче параметров в PDO или построитель запросов передается свойство value:

declare(strict_types=1);

$status = OrderStatus::Paid;

$stmt = $pdo->prepare('UPDATE orders SET status = :status WHERE id = :id');
$stmt->execute([
    'status' => $status->value,
    'id' => $orderId,
]);

Восстановление (гидрация)

При выборке из базы данных строковое значение оборачивается через from():

declare(strict_types=1);

$stmt = $pdo->prepare('SELECT status FROM orders WHERE id = :id');
$stmt->execute(['id' => $orderId]);
$raw = $stmt->fetchColumn();

if ($raw === false) {
    throw new \RuntimeException('Заказ не найден');
}

// Строгая гидрация. Если в БД записан мусор, приложение упадет с ValueError,
// предотвратив дальнейшую работу с некорректным состоянием.
$status = OrderStatus::from((string) $raw);

JSON-сериализация

По умолчанию функция json_encode() преобразует экземпляр Backed Enum в объект со свойством name или пустой объект в зависимости от окружения, но не в скаляр. Чтобы сериализовать enum как чистую строку, реализуйте интерфейс \JsonSerializable:

declare(strict_types=1);

namespace App\Domain\Order;

enum OrderStatus: string implements \JsonSerializable
{
    case New = 'new';
    case Paid = 'paid';

    public function jsonSerialize(): string
    {
        return $this->value;
    }
}

// Теперь json_encode($status) вернет строку "new"

Эволюция статусов в продакшене без потери данных

Рано или поздно бизнес требует изменить цепочку состояний: добавить промежуточный статус, переименовать существующий или убрать устаревший. Неправильный порядок развертывания может привести к падению воркеров очередей и сбоям в чтении базы данных.

1. Добавление нового статуса

Если в систему добавляется статус OrderStatus::Processing = 'processing':

  1. Первый релиз (Код): Добавьте кейс в перечисление и обновите все конструкции match. Разверните код на всех серверах и воркерах. На этом этапе система уже способна десериализовать новый статус, но еще не создает его.
  2. Второй релиз (Бизнес-логика): Включите генерацию нового статуса (в API, интерфейсе или сценариях оплаты).

Если нарушить этот порядок и сначала обновить данные в БД, старые воркеры при попытке прочитать строку 'processing' упадут с ValueError.

2. Переименование или удаление статуса

Прямое переименование кейса в коде недопустимо. Если переименовать Paid в Completed, вся история заказов в базе данных перестанет читаться.

Безопасный алгоритм:

  1. Создайте миграцию данных в БД, которая обновляет значения в колонке:
    UPDATE orders SET status = 'completed' WHERE status = 'paid';
    
  2. Поддерживайте обратную совместимость на уровне кода в период миграции:
    public static function fromLegacy(string $value): self
    {
        return match ($value) {
            'paid' => self::Completed,
            default => self::from($value),
        };
    }
    
  3. И только после полной конвертации данных в таблицах и сообщениях очередей удаляйте устаревший кейс из кода.

3. Поддержка архивных данных

Если статус выведен из оборота (например, старая схема предзаказов PreOrder), но в БД остаются тысячи исторических заказов, кейс нельзя удалять из Enum.

Вместо удаления пометьте его как архивный в логике приложения:

public function isArchived(): bool
{
    return match ($this) {
        self::PreOrder => true,
        default => false,
    };
}

public function isSelectableForNewOrders(): bool
{
    return !$this->isArchived();
}

Чек-лист безопасной работы с Enums в e-commerce

  • [ ] Всегда объявляйте declare(strict_types=1); во всех файлах проекта.
  • [ ] Используйте string в качестве backing value для сущностей, сохраняемых в базу данных.
  • [ ] Применяйте from() для данных из доверенных источников (собственная БД) и tryFrom() с обязательной валидацией null для внешних API и форм.
  • [ ] Никогда не применяйте оператор объединения с null (??) для подстановки дефолтного статуса при сбое парсинга без генерации ошибки или предупреждения в журнал.
  • [ ] Ветвите логику через match без блока default, чтобы компилятор и линтер гарантировали обработку всех состояний.
  • [ ] Реализуйте \JsonSerializable, если перечисления отправляются во фронтенд через JSON.

Часто задаваемые вопросы (FAQ)

Что выбрать для хранения статуса заказа в БД: string или int?

Практичнее использовать string (например, 'paid', 'cancelled'). Числовые статусы экономят несколько байт, но делают сырые дампы БД нечитаемыми без документации. Кроме того, случайное смещение ID статуса при разработке может исказить бизнес-логику, тогда как строковый идентификатор уникален и нагляден.

Как получить список всех значений Enum для валидации формы?

У всех перечислений PHP есть встроенный статический метод cases(), возвращающий массив всех вариантов:

$allowedValues = array_column(OrderStatus::cases(), 'value');
// Результат: ['new', 'pending_payment', 'paid', 'shipped', 'cancelled']

Что произойдет, если в очередь RabbitMQ или Redis упадет задача со статусом, которого нет в коде воркера?

Метод OrderStatus::from($payload['status']) выбросит исключение \ValueError. Задача завершится с ошибкой и будет перемещена в очередь недоставленных сообщений (Dead Letter Queue). Это убережет систему от частичного или неверного выполнения сценария.

Можно ли наследовать один Enum от другого в PHP?

Нет. В PHP перечисления не поддерживают наследование (extends). Запрещено как наследовать enum от другого enum, так и наследовать enum от обычного класса. Для переиспользования общей логики между разными перечислениями используйте интерфейсы и трейты.

Почему не стоит использовать блок default в match при обработке статусов?

Если написать default => ..., рантайм перестанет требовать исчерпывающего перечисления всех кейсов. При добавлении нового статуса заказа (например, Refunded) вы не получите ошибку UnhandledMatchError, и заказ пойдет по непредвиденной ветке логики по умолчанию.


Вывод

Enums в PHP 8.1+ — это не просто синтаксический сахар для подстановки констант, а полноценный инструмент обеспечения типобезопасности. Перевод статусов заказов на Backed Enums в связке с оператором match исключает появление невалидных состояний на уровне компиляции и рантайма. Главное правило при эксплуатации такой архитектуры — избегать молчаливого проглатывания неизвестных значений и планировать миграции схемы данных синхронно с деплоем кодовой базы.

Источники

Это авторская статья, основанная на личном опыте и субъективном взгляде автора. Заметили ошибку или битую ссылку? Сообщите нам: info@codesrc.ru - мы оперативно исправим. Спасибо, что помогаете делать блог лучше.
Следите за нами в соцсетях:

Читайте также