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

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

Параметр

Назначение

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.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

  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.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,
        ]);
    }
}

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

  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.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.

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