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, основные понятия
Прежде чем переходить к настройке компонента, разберёмся с его терминологией. Все понятия напрямую соответствуют привычным элементам бизнес-процесса:
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.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.