וואלהמשרד הבריאות מזהיר: כדורי ההרזיה G-Slim מכילים חומר תרופתי אסורRTP DesportoJoão Almeida nos convocados para os Mundiais de ciclismo de estradaPunch2027: Reps deputy spokesman backs Tinubu with new mobilisation groupThe Jerusalem PostDenis Mukin sentenced to 22 years for murder of Diar Omari in road disputeInquirer EntertainmentLOOK: Empress Schuck expecting baby girlInquirerFilipino historian named honorary professor in MexicoDaily MaverickCoalition likely in Philippines’ Muslim south with no clear winner in electionColliderPrincess Donut Officially Gets Her Own ‘Dungeon Crawler Carl’ ReleaseSouth China Morning PostWho will shoulder the debts behind Indonesia’s China-backed Whoosh railway?Sky TG24Legge elettorale, Calenda: "Perpetua scontro nel Paese"Deadline‘We Will Dance Again’ Exec Joins Israel’s Yoav Gross Productions To Run ContentStraits Times SportBit of luck to go a long way for All About Al
The Daily Newsstand · Free, Always
Tuesday, September 15, 2026

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

Translate

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

View the original on Хабр

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.