
Заказ в интернет-магазине редко живёт по идеальной схеме «создан → оплачен → доставлен». В реальности жизненный цикл объекта нелинейный: клиент оформляет заказ, но может передумать и отменить его; товар приходит с браком и требуется возврат; часть позиций не успела прийти и нужно отправить их отдельно. Попытка реализовать такие альтернативные сценарии, проверки прав и сопутствующие действия «в лоб» быстро превращает бизнес-логику в хаос из разрозненных 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, основные понятия
Прежде чем переходить к настройке компонента, разберёмся с его терминологией. Все понятия напрямую соответствуют привычным элементам бизнес-процесса:
Place (место/состояние) — состояние, в котором находится объект. В рассматриваемом примере заказа такими состояниями выступают
new, pending_payment, paidи т. д. В большинстве проектов они хранятся в виде Enum или строкового значения в базе данных.Transition (переход) — действие, переводящее объект из одного состояния в другое (например,
submit— переход изnewвpending_payment). Обратите внимание: переход имеет собственное имя. В данном контексте говорят не «изменить статус наpaid», а «выполнить переходpay». Благодаря этому код отражает бизнес-действия, а не просто присваивает новое значение полю.Marking (метка) — текущее состояние объекта (или набор состояний). В случае
state_machineметка ровно одна и соответствует текущему статусу заказа.
Обычно метка хранится в одном из полей сущности:
class Order { private string $status = 'new'; }
Именно это поле Symfony Workflow будет читать и изменять при выполнении переходов.
Workflow vs State Machine
Symfony поддерживает два режима работы: Workflow и State Machine. Главное различие заключается в количестве одновременно активных состояний:
State Machine допускает только одно активное состояние в каждый момент времени. Для большинства бизнес-сущностей — заказа, счёта, заявки, договора — подходит именно этот режим. Заказ не может одновременно быть «оплачен» и «отменён».
Workflow позволяет объекту одновременно находиться сразу в нескольких состояниях. Например, документ может параллельно находиться на согласовании у юридического отдела, на проверке службы безопасности и на утверждении у руководителя.
Далее мы будем использовать State Machine, так как этот режим идеально соответствует классическому жизненному циклу заказа и является наиболее распространённым сценарием.
Окружение
Для воспроизведения примеров из статьи подготовьте окружение. Вы можете клонировать готовый репозиторий с примером или создать новый проект Symfony самостоятельно. Используемый стек:
PHP 8.3+ (или PHP 8.5)
Nginx / Web-сервер
PostgreSQL
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.php enum 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.php namespace 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
Ключевые параметры
Параметр | Назначение |
|---|---|
| Используется режим, в котором одновременно может быть только одно активное состояние |
| Указывает свойство сущности ( |
| Определяет классы объектов, с которыми работает данный Workflow |
| Начальное состояние объекта при создании |
| Полный список возможных состояний |
| Описание разрешённых переходов между состояниями |
| Включает логирование операций 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.php namespace 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
can($object, 'transition_name')— проверяет, допустим ли переход из текущего состояния.getEnabledTransitions($object)— возвращает массив всех переходов, доступных для объекта в данный момент (удобно использовать для динамического построения UI или ответов API).apply($object, 'transition_name')— выполняет переход и меняет состояние объекта в памяти.
Важное замечание: Метод apply() меняет состояние объекта исключительно в памяти PHP. Для сохранения изменений в базе данных необходимо явно вызывать метод flush() у EntityManager Doctrine.
Проверка работы
Рассмотрим пример использования Workflow в REST-контроллере.
// src/Controller/OrderController.php namespace 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, ]); } }
Пример взаимодействия
Создание заказа:
POST /orders
Ответ:{"id": 1, "status": "new"}Попытка недопустимого перехода:
POST /orders/1/transitionsс телом{"transition": "pay"}
Ответ:
{ "error": "Transition \"pay\" is not allowed for order #1 in status \"new\".", "availableTransitions": ["submit", "cancel"] }
Правильная цепочка переходов:
POST /orders/1/transitions ({"transition": "submit"})-> Status:pending_paymentPOST /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.php namespace 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.php namespace 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.

