Symfony Workflow: как реализовать сложную бизнес-логику через состояния и переходы

от автора

Заказ в интернет-магазине редко живёт по идеальной схеме «создан → оплачен → доставлен». В реальности жизненный цикл объекта нелинейный: клиент оформляет заказ, но может передумать и отменить его; товар приходит с браком и требуется возврат; часть позиций не успела прийти и нужно отправить их отдельно. Попытка реализовать такие альтернативные сценарии, проверки прав и сопутствующие действия «в лоб» быстро превращает бизнес-логику в хаос из разрозненных if/else, проверок статусов и обработчиков по всему проекту.

В статье разберём, как с помощью компонента Symfony Workflow описывать сложные бизнес-процессы в виде явной модели состояний и переходов. На практическом примере рассмотрим, как задавать допустимые переходы, добавлять бизнес-правила и проверки, обрабатывать события и отделять описание процесса от кода, выполняющего конкретные действия. В результате получим не просто механизм управления статусами, а инструмент, который делает сложную бизнес-логику понятной, предсказуемой и удобной для сопровождения.

Практический пример: Специально для статьи мы подготовили репозиторий на GitHub — symfony_workflow_lesson, который можно скопировать для разбора примеров кода.

Пользуясь случаем, команда FirstVDS горячо поздравляет с прошедшим Днём программиста всех IT-героев! В честь этого события дарим промокод со скидкой 25% на выбранный период заказа (1, 3, 6, 12 месяцев) новых VDS в России, Нидерландах или Казахстане. Успейте активировать скидку!

Какую проблему решает Workflow

Для начала рассмотрим упрощённую реализацию смены статуса заказа без использования специальных компонентов:

public function pay(Order $order): void{    if ($order->getStatus() !== OrderStatus::PendingPayment) {        throw new \DomainException('Order is not awaiting payment');    }    $order->setStatus(OrderStatus::Paid);}

На первый взгляд всё выглядит достаточно просто: проверяем текущее состояние и меняем его на новое. Однако в реальном проекте жизненный цикл объекта редко ограничивается двумя-тремя статусами. Появляются отмена заказа, возврат средств, повторная оплата, частичная доставка, разграничение ролей пользователей, а также сопутствующие действия при каждом переходе: отправка уведомлений, резервирование или списание товара, публикация событий, запись в журнал аудита.

В результате каждый метод начинает обрастать дублирующимися проверками текущего состояния, условий перехода и прав доступа. Со временем бизнес-правила оказываются распределены по десяткам сервисов, а понять, какие переходы вообще допустимы, становится всё сложнее. Любое изменение процесса требует поиска подобной логики по всему проекту, из-за чего вероятность ошибки постоянно растёт. Именно эту проблему решает Symfony Workflow. Компонент позволяет вынести правила переходов в единое централизованное описание, а приложению остаётся лишь запрашивать разрешение на переход и выполнять его:

Без Workflow 

С Workflow

Правила переходов распределены по сервисам и обработчикам 

Все состояния и переходы описаны в одном месте

Проверки приходится писать вручную

Недопустимые переходы блокируются автоматически

Легко забыть добавить проверку в новом коде

Все переходы проходят через единый механизм

Сложно понять жизненный цикл объекта 

Процесс можно визуализировать в виде схемы

Бизнес-логика смешивается с прикладным кодом

Правила процесса отделены от бизнес-логики приложения

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

Что такое Symfony Workflow, основные понятия

Прежде чем переходить к настройке компонента, разберёмся с его терминологией. Все понятия напрямую соответствуют привычным элементам бизнес-процесса:

  1. Place (место/состояние) — состояние, в котором находится объект. В рассматриваемом примере заказа такими состояниями выступают new, pending_payment, paid и т. д. В большинстве проектов они хранятся в виде Enum или строкового значения в базе данных.

  2. Transition (переход) — действие, переводящее объект из одного состояния в другое (например, submit — переход из new в pending_payment). Обратите внимание: переход имеет собственное имя. В данном контексте говорят не «изменить статус на paid», а «выполнить переход pay». Благодаря этому код отражает бизнес-действия, а не просто присваивает новое значение полю.

  3. Marking (метка) — текущее состояние объекта (или набор состояний). В случае state_machine метка ровно одна и соответствует текущему статусу заказа.

Обычно метка хранится в одном из полей сущности:

class Order {    private string $status = 'new';}

Именно это поле Symfony Workflow будет читать и изменять при выполнении переходов.

Workflow vs State Machine

Symfony поддерживает два режима работы: Workflow и State Machine. Главное различие заключается в количестве одновременно активных состояний:

  1. State Machine допускает только одно активное состояние в каждый момент времени. Для большинства бизнес-сущностей — заказа, счёта, заявки, договора — подходит именно этот режим. Заказ не может одновременно быть «оплачен» и «отменён».

  2. Workflow позволяет объекту одновременно находиться сразу в нескольких состояниях. Например, документ может параллельно находиться на согласовании у юридического отдела, на проверке службы безопасности и на утверждении у руководителя.

Далее мы будем использовать State Machine, так как этот режим идеально соответствует классическому жизненному циклу заказа и является наиболее распространённым сценарием.

Окружение

Для воспроизведения примеров из статьи подготовьте окружение. Вы можете клонировать готовый репозиторий с примером или создать новый проект Symfony самостоятельно. Используемый стек:

  1. PHP 8.3+ (или PHP 8.5)

  2. Nginx / Web-сервер

  3. PostgreSQL

  4. Docker Compose

После клонирования репозитория достаточно запустить контейнеры:

docker compose up -d

Установите необходимый компонент Symfony Workflow и Doctrine ORM:

docker compose exec php composer require symfony/workflow doctrine

После установки Flex автоматически создаст базовую конфигурацию подключения к базе данных.

Модель данных: заказ и статусы

Для демонстрации работы Workflow будем использовать упрощённую сущность заказа. Нас интересует жизненный цикл объекта, поэтому сосредоточимся на поле $status. Создадим Enum статусов:

// src/Enum/OrderStatus.phpenum OrderStatus: string{    case New = 'new';    case PendingPayment = 'pending_payment';    case Paid = 'paid';    case Processing = 'processing';    case Shipped = 'shipped';    case Delivered = 'delivered';    case Cancelled = 'cancelled';    case Refunded = 'refunded';}

Важно: Строковые значения Enum (new, pending_payment, paid и т. д.) должны строго совпадать с именами places, описанными в конфигурации Workflow.

Сущность Order

Теперь создадим саму сущность:

// src/Entity/Order.phpnamespace App\Entity;use App\Enum\OrderStatus;use Doctrine\ORM\Mapping as ORM;#[ORM\Entity]#[ORM\Table(name: 'orders')]class Order{    #[ORM\Id]    #[ORM\GeneratedValue]    #[ORM\Column(type: 'integer')]    private ?int $id = null;    #[ORM\Column(enumType: OrderStatus::class)]    private OrderStatus $status = OrderStatus::New;    public function getId(): ?int    {        return $this->id;    }    public function getStatus(): OrderStatus    {        return $this->status;    }    public function setStatus(OrderStatus $status): static    {        $this->status = $status;        return $this;    }}

Symfony Workflow не требует наследования от специальных базовых классов, подключения трейтов или реализации сторонних интерфейсов. Достаточно указать в конфигурации свойство, хранящее состояние (marking_store.property), и компонент будет автоматически читать и обновлять его через getter и setter (getStatus() / setStatus()).

Конфигурация Workflow

Теперь опишем жизненный цикл заказа в конфигурации Symfony Workflow. Все допустимые состояния и переходы будут находиться в config/packages/workflow.yaml:

framework:    workflows:        order:            type: state_machine            audit_trail:                enabled: true            marking_store:                type: method                property: status            supports:                - App\Entity\Order            initial_marking: new            places:                - new                - pending_payment                - paid                - processing                - shipped                - delivered                - cancelled                - refunded            transitions:                submit:                    from: new                    to: pending_payment                pay:                    from: pending_payment                    to: paid                process:                    from: paid                    to: processing                ship:                    from: processing                    to: shipped                deliver:                    from: shipped                    to: delivered                cancel:                    from: [new, pending_payment, paid, processing]                    to: cancelled                refund:                    from: [paid, processing, shipped, delivered]                    to: refunded

Ключевые параметры

Параметр

Назначение

type: state_machine

Используется режим, в котором одновременно может быть только одно активное состояние

marking_store.property: status

Указывает свойство сущности (status), хранящее текущее состояние

supports

Определяет классы объектов, с которыми работает данный Workflow

initial_marking: new

Начальное состояние объекта при создании

places

Полный список возможных состояний

transitions

Описание разрешённых переходов между состояниями

audit_trail.enabled: true

Включает логирование операций Workflow (удобно при отладке)

Обратите внимание на объявление сложных переходов:

cancel:     from: [new, pending_payment, paid, processing]    to: cancelled

Отменить заказ можно из четырёх различных состояний, но итоговый результат всегда один — cancelled. Если попытаться выполнить cancel для заказа в состоянии shipped, Workflow не найдёт подходящего правила и заблокирует операцию.

Сервисный слой

После загрузки конфигурации Symfony автоматически регистрирует сервис State Machine в DI-контейнере. Для конфигурации с именем order и типом state_machine сервису будет присвоен идентификатор state_machine.order. Создадим сервис-обёртку OrderWorkflowService для управления переходами:

// src/Service/OrderWorkflowService.phpnamespace App\Service;use App\Entity\Order;use LogicException;use Symfony\Component\Workflow\WorkflowInterface;final readonly class OrderWorkflowService{    public function __construct(        private WorkflowInterface $orderStateMachine,    ) {}    public function getEnabledTransitions(Order $order): array    {        return array_map(            static fn ($transition) => $transition->getName(),            $this->orderStateMachine->getEnabledTransitions($order)        );    }    public function apply(Order $order, string $transition): void    {        if (!$this->orderStateMachine->can($order, $transition)) {            throw new LogicException(sprintf(                'Transition "%s" is not allowed for order #%s in status "%s".',                $transition,                $order->getId() ?? 'new',                $order->getStatus()->value            ));        }        $this->orderStateMachine->apply($order, $transition);    }}

Конфигурация подключения сервиса в config/services.yaml:

services:    App\Service\OrderWorkflowService:        arguments:            $orderStateMachine: '@state_machine.order'

Основные методы работы с Workflow

  1. can($object, 'transition_name') — проверяет, допустим ли переход из текущего состояния.

  2. getEnabledTransitions($object) — возвращает массив всех переходов, доступных для объекта в данный момент (удобно использовать для динамического построения UI или ответов API).

  3. apply($object, 'transition_name') — выполняет переход и меняет состояние объекта в памяти.

Важное замечание: Метод apply() меняет состояние объекта исключительно в памяти PHP. Для сохранения изменений в базе данных необходимо явно вызывать метод flush() у EntityManager Doctrine.

Проверка работы

Рассмотрим пример использования Workflow в REST-контроллере.

// src/Controller/OrderController.phpnamespace App\Controller;use App\Entity\Order;use App\Service\OrderWorkflowService;use Doctrine\ORM\EntityManagerInterface;use LogicException;use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;use Symfony\Component\HttpFoundation\JsonResponse;use Symfony\Component\HttpFoundation\Request;use Symfony\Component\HttpFoundation\Response;use Symfony\Component\Routing\Annotation\Route;#[Route('/orders')]class OrderController extends AbstractController{    public function __construct(        private EntityManagerInterface $entityManager,        private OrderWorkflowService $orderWorkflow,    ) {}    #[Route('', methods: ['POST'])]    public function create(): JsonResponse    {        $order = new Order();        $this->entityManager->persist($order);        $this->entityManager->flush();        return $this->json([            'id' => $order->getId(),            'status' => $order->getStatus()->value,        ], Response::HTTP_CREATED);    }    #[Route('/{id}/transitions', methods: ['POST'])]    public function applyTransition(int $id, Request $request): JsonResponse    {        $data = json_decode($request->getContent(), true);        $transition = $data['transition'] ?? '';        $order = $this->entityManager->getRepository(Order::class)->find($id);        if (!$order) {            return $this->json(['error' => 'Order not found'], Response::HTTP_NOT_FOUND);        }        try {            $this->orderWorkflow->apply($order, $transition);            $this->entityManager->flush();        } catch (LogicException $exception) {            return $this->json([                'error' => $exception->getMessage(),                'availableTransitions' => $this->orderWorkflow->getEnabledTransitions($order),            ], Response::HTTP_UNPROCESSABLE_ENTITY);        }        return $this->json([            'id' => $order->getId(),            'status' => $order->getStatus()->value,        ]);    }}

Пример взаимодействия

  1. Создание заказа: POST /orders
    Ответ: {"id": 1, "status": "new"}

  2. Попытка недопустимого перехода: POST /orders/1/transitions с телом {"transition": "pay"}
    Ответ:

{    "error": "Transition \"pay\" is not allowed for order #1 in status \"new\".",    "availableTransitions": ["submit", "cancel"]}

Правильная цепочка переходов:

  1. POST /orders/1/transitions ({"transition": "submit"}) -> Status: pending_payment

  2. POST /orders/1/transitions ({"transition": "pay"}) -> Status: paid

Мы рассмотрели базовые примеры перехода из одного состояния в другое (submit и pay). Все остальные переходы (process, ship, deliver, cancel, refund) осуществляются аналогично: передачей имени нужного перехода в метод apply(). Symfony Workflow автоматически сверит текущий статус заказа с описанной конфигурацией и выполнит смену состояния, если шаг разрешён.

Продвинутые возможности: события, Guard’ы и метаданные

1. Обработка событий (Event Subscribers)

Symfony Workflow генерирует цепочку событий на разных этапах перехода (guard, leave, transition, enter, entered, completed).

Пример слушателя, отправляющего уведомление после успешной оплаты:

// src/EventListener/OrderPaidListener.phpnamespace App\EventListener;use App\Entity\Order;use Symfony\Component\EventDispatcher\Attribute\AsEventListener;use Symfony\Component\Workflow\Event\CompletedEvent;#[AsEventListener(event: 'workflow.order.completed.pay')]class OrderPaidListener{    public function __invoke(CompletedEvent $event): void    {        /** @var Order $order */        $order = $event->getSubject();        // Логика отправки письма или публикация доменного события    }}

2. Дополнительные проверки (Guard Events)

Если для выполнения перехода недостаточно знать только текущее состояние (например, требуется проверка прав доступа или баланса), используются Guard-события:

// src/EventListener/OrderCancelGuard.phpnamespace App\EventListener;use Symfony\Component\EventDispatcher\Attribute\AsEventListener;use Symfony\Component\Workflow\Event\GuardEvent;use Symfony\Bundle\SecurityBundle\Security;#[AsEventListener(event: 'workflow.order.guard.cancel')]class OrderCancelGuard{    public function __construct(private Security $security) {}    public function __invoke(GuardEvent $event): void    {        if (!$this->security->isGranted('ROLE_ADMIN')) {            $event->setBlocked(true, 'Отменить заказ может только администратор.');        }    }}

3. Метаданные (Metadata)

Вы можете привязывать дополнительную информацию (человекочитаемые названия, цвета, иконки) прямо к состояниям и переходам в YAML-конфигурации:

places:    paid:        metadata:            label: 'Оплачен'            badge_color: 'green'transitions:    pay:        from: pending_payment        to: paid        metadata:            label: 'Оплатить заказ'

Получить метаданные в PHP-коде можно через объект WorkflowMetadataStore:

$title = $workflow->getMetadataStore()->getPlaceMetadata('paid')['label'];

4. Визуализация схем

Вы можете экспортировать описанный Workflow в формат Graphviz (DOT) или PlantUML для генерации наглядных диаграмм. Команда для генерации DOT-файла через консоль Symfony:

php bin/console workflow:dump order | dot -Tpng -o workflow.png

Сгенерированная схема наглядно покажет все места, переходы и ветвления процесса, заменяя собой устаревающую текстовую документацию.

Заключение

Использование Symfony Workflow позволяет отказаться от разрозненных проверок и превратить смену состояний в четко контролируемый процесс. Вы выносите правила жизненного цикла в единую декларативную модель, делая архитектуру приложения чище и надежнее.

Ключевые преимущества:

  • Прозрачность: все состояния и переходы описаны в одном файле (workflow.yaml), который служит наглядной документацией процесса.

  • Надежность: компонент гарантирует целостность данных и автоматически блокирует любые недопустимые переходы.

  • Разделение ответственности: Переход отвечает только за смену статуса, а побочные эффекты (уведомления, списание баланса, интеграции) легко выносятся в событийно-ориентированные слушатели (Event Subscribers).

  • Гибкость и масштабируемость: Добавление новых состояний, Guard-проверок прав или интеграций не требует переписывания основной бизнес-логики.

Если жизненный цикл сущности выходит за рамки простых двух-трех статусов и обрастает условиями, альтернативными ветками и ролями — Symfony Workflow становится удобным архитектурным инструментом, гарантирующим предсказуемость и простоту поддержки системы.


НЛО прилетело и оставило здесь промокод для читателей нашего блога:
-15% на заказ нового VDS — HABRFIRSTVDS.

Положение об акции

ссылка на оригинал статьи https://habr.com/ru/articles/1082284/