Перейти к содержимому
Поиск
Разработка

Symfony Workflow: процессы в PHP — мастерская и 9 практических ситуаций

Девять практических ситуаций на Symfony Workflow: заявки, права, параллельные проверки, SQLite, конфликты версий и идемпотентность. Мастерская с записью PHP, исходники и запуск Windows/Linux.

Александр Некрасов
Symfony Workflow: процессы в PHP — мастерская и 9 практических ситуаций — обложка

Symfony Workflow без фреймворка: заявки, согласования, guards, SQLite, конфликты и повторы. Девять практических опытов, запись PHP и исходники для Windows/Linux.

Symfony Workflow: правила для заявок, которые нельзя перепрыгнуть

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

В этой статье мы построим процесс сервисной заявки без полного фреймворка Symfony. Будут черновик, отправка, решение, обязательная причина возврата, две независимые проверки документа и сохранение в SQLite. Разберём не только успешные переходы: попробуем согласовать черновик, превысим полномочия менеджера, отправим устаревшую версию карточки и повторим внешнее событие. Именно эти ситуации делают компонент полезным в реальном приложении.

На странице работает мини-мастерская с девятью ситуациями и тридцатью шагами. Вы сможете менять выбранный опыт, просматривать состояние до и после, читать причины отказа, разбирать последовательность событий и скачивать результат. Под мастерской — объяснения, PHP-код, запуск на Windows и Linux, устройство хранения и рекомендации для VPS. В конце есть полный проект с Composer lock и тестами, который можно перенести в собственную разработку.

Где применять: CRM, согласования, документы и интеграции

Представьте сервисную компанию. Оператор оформляет заявку, менеджер проверяет смету, руководитель согласует крупные суммы. Сотрудники видят одну карточку, но имеют разные доступные действия. При возврате клиенту нужно показать причину, а бухгалтерии — отправить событие только после успешного подтверждения. Здесь Workflow становится центральным местом для ответа на вопрос: «Можно ли сейчас выполнить это действие над этой заявкой?»

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

Для простого переключателя «включено / выключено» отдельный компонент может быть избыточен. Но когда одинаковые правила нужны веб-интерфейсу, CLI-команде, очереди и webhook, единое определение заметно упрощает сопровождение. Разработчик меняет процесс в одном месте, а тесты проверяют все входные пути. При этом сам компонент не создаёт CRM, не хранит пользователей и не выполняет доставку сообщений: эти части мы соединяем с ним явно.

Три понятия: place, transition и marking

Место, или place, описывает положение объекта в процессе. В нашей заявке это draft, submitted, approved и rejected. Переход, или transition, означает действие: submit, approve, reject, reopen. Разделение полезно для языка продукта: «согласовать» — команда сотрудника, «согласовано» — уже полученный результат.

Маркировка, или marking, показывает текущие активные места конкретного объекта. Определение процесса общее для всех заявок, но marking у каждой свой. В мастерской зелёные блоки схемы берутся непосредственно из записанного PHP-результата. Если операция запрещена, блок остаётся на прежнем месте; ошибка сама по себе не становится новым статусом.

Не привязывайте техническое имя перехода к переводу кнопки. Код approve удобно хранить в логах и API, а текст «Согласовать» переводить в интерфейсе. Аналогично номер заявки и текущая сумма относятся к доменным данным, а не к определению графа. Благодаря этому один процесс обслуживает тысячи объектов с разными значениями и разными причинами запрета.

use Workshop\Processes;
use Workshop\Ticket;

$workflow = (new Processes())->ticket();
$ticket = new Ticket(id: 'REQ-1042');
$workflow->apply($ticket, 'submit');

echo $ticket->marking; // submitted

Версии, Composer и работа без полного Symfony

В скачиваемом проекте зафиксированы symfony/workflow 7.4.20 и symfony/event-dispatcher 7.4.17. Выбрана поддерживаемая ветка 7.4 LTS и PHP 8.2+, чтобы пример можно было использовать в существующем PHP-приложении. Это версии конкретных компонентов в нашем lock, а не утверждение о последнем общем patch-релизе всего Symfony. Ветка 7.4 и её требования описаны на официальной странице релиза.

Нам не нужны Kernel, контейнер сервисов, маршрутизатор или Symfony Console. Автозагрузчик Composer подключает обычные PHP-классы, а Definition, StateMachine и EventDispatcher создаются явно. Такой вариант удобно встроить в старый сайт, небольшое API, административную панель или обработчик очереди. При переходе к полноценному Symfony доменную идею можно сохранить, а создание объектов передать конфигурации приложения.

Для скачанного архива выполняйте composer install: он использует приложенный lock. Команда update нужна для осознанного обновления зависимостей с повторными тестами. В новом проекте можно начать с команды ниже. Подробно установка, автозагрузка и различие install/update разобраны в нашем руководстве по Composer; правила lock также описаны в документации Composer.

composer require "symfony/workflow:^7.4" "symfony/event-dispatcher:^7.4"
composer check-platform-reqs
composer audit

Мини-мастерская: девять ситуаций и тридцать шагов

Начните с жизненного цикла заявки. Нажмите «Следующий шаг»: первая попытка согласовать черновик завершится отказом. Следующий шаг отправит заявку на проверку, затем появится успешное согласование. Вы увидите, что одинаковое действие approve имеет разный результат в зависимости от текущего состояния. В списке можно сразу перейти к нужному кадру и сравнить значения до и после.

Затем выберите роли и лимит суммы, возврат на доработку, параллельное согласование, события, SQLite, конфликт клиентов, идемпотентность или карточку оператора. У каждого опыта есть практическая ситуация и объяснение результата. Блок «Что разрешено и почему» показывает доступные переходы и причины запретов. Под раскрывающимися разделами находятся события, ответ процесса, исходный PHP и команды запуска для выбранной ОС.

Важная граница: мастерская воспроизводит запись настоящих запусков PHP и SQLite. Нажатие кнопки показывает следующий сохранённый результат; оно не исполняет PHP в браузере и не меняет серверные заявки. Мы не заменяем Symfony отдельным JavaScript-автоматом. Для своих сумм, правил и ролей скачайте проект, измените PHP и выполните capture. Запись содержит версии среды и хеши исходников, а тест проверяет их соответствие.

State machine и workflow: одно состояние или несколько

В сервисной заявке достаточно одного текущего статуса. Черновик не должен одновременно быть согласованным и возвращённым. Поэтому для неё мы создаём StateMachine и используем строковое свойство marking. В примере с документом другая задача: юрист и инженер проверяют его независимо. После запуска активны сразу два места, а завершение возможно только после обеих проверок. Для этого применяется Workflow с массивом активных мест.

Практическая ошибка — пытаться перечислить все комбинации независимых проверок одним огромным статусом. Сначала появляются «юрист готов, инженер ждёт» и «инженер готов, юрист ждёт», затем добавляется безопасность и количество комбинаций растёт. Отдельные места позволяют показать независимость веток прямо в процессе. При этом обычный статус заявки остаётся проще там, где независимости нет.

Не воспринимайте выбор типа как вопрос вкуса. Сначала сформулируйте предметную ситуацию и проверьте, должен ли объект находиться в нескольких местах одновременно. Различие этих моделей описано в официальном объяснении Workflow и State Machine. В нашем архиве модели разделены на Ticket и Review, поэтому их нельзя случайно смешать одним свойством.

// Одна строка: draft, submitted, approved или rejected.
new MethodMarkingStore(true, 'marking');

// Несколько мест: ['legal_pending' => 1, 'tech_pending' => 1].
new MethodMarkingStore(false, 'marking');

Опыт 1. Жизненный цикл заявки и запрещённый переход

В первом опыте есть четыре места и четыре действия. Из draft можно отправить заявку; из submitted — согласовать или вернуть; из rejected — открыть доработку. Для approved обратного перехода в этой учебной модели нет. Это осознанное правило примера, а не ограничение библиотеки: в вашем бизнесе отмена решения может быть отдельным действием с дополнительными правами и причиной.

Попытка approve из draft полезнее первого успешного клика. Она показывает, что правило выполняется серверной библиотекой, даже если вызывающий код ошибся. После отказа marking остаётся draft. В интерфейсе нужно сохранить введённые данные, объяснить проблему и предложить допустимое действие. Присваивать approved вручную после исключения означало бы обойти весь смысл процесса.

Сначала перечислите реальные действия пользователя на бумаге. Если сотрудник говорит «я отправляю на проверку», называйте переход submit, а не set_status_2. Такие имена проще обсуждать с заказчиком и читать в журнале. Затем добавьте тесты всех запрещённых направлений, которые могут привести к ущербу: повторное согласование, завершение до проверки, возврат из окончательно закрытого состояния.

$definition = new Definition(
    ['draft', 'submitted', 'approved', 'rejected'],
    [
        new Transition('submit', 'draft', 'submitted'),
        new Transition('approve', 'submitted', 'approved'),
        new Transition('reject', 'submitted', 'rejected'),
        new Transition('reopen', 'rejected', 'draft'),
    ],
    'draft',
);

can, apply и исключения: проверка не заменяет действие

Метод can удобен, когда нужно показать доступность кнопки. Но результат этой проверки относится к конкретному объекту и конкретному моменту. Пока пользователь читает карточку, другой сотрудник может изменить заявку. Поэтому обработчик действия снова загружает данные, проверяет доступ и применяет переход. Нельзя доверять присланному из браузера значению «можно согласовать».

Сам apply может завершиться исключением. В нашем опыте это ожидаемый результат запрещённого перехода, а не поломка сервера. В API такой случай нужно превратить в понятный ответ с кодом ошибки и причинами. Для конфликта версии карточки удобно использовать HTTP 409; для неподходящих полей запроса — 422. Конкретный контракт определяет ваше приложение: Symfony Workflow сам не выбирает HTTP-статус.

Не оборачивайте весь сервис в catch, который всегда возвращает success. Тогда интерфейс покажет успех при неизменившейся базе. Отделяйте ожидаемые доменные отказы от неожиданных ошибок инфраструктуры. Первые можно объяснить пользователю, вторые нужно зафиксировать в серверном журнале с идентификатором запроса и вернуть нейтральное сообщение без SQL, путей и трассировки.

use Symfony\Component\Workflow\Exception\NotEnabledTransitionException;

try {
    $workflow->apply($ticket, 'approve');
} catch (NotEnabledTransitionException $error) {
    $blockers = $workflow->buildTransitionBlockerList($ticket, 'approve');
    foreach ($blockers as $blocker) {
        // В API сформируйте структурированный ответ.
        echo $blocker->getMessage(), PHP_EOL;
    }
}

Опыт 2. Guards: роль сотрудника и предел суммы

Во втором опыте заявка уже submitted, но сумма составляет 150 000 рублей. Operator получает сразу две причины отказа: недостаточную роль и превышение лимита. Manager проходит проверку роли, но всё ещё не может согласовать эту сумму. Director успешно выполняет approve. Все три результата получены настоящим PHP-кодом; мастерская только даёт сравнить записанные ответы.

Guard удобно держать рядом с определением процесса, чтобы правила не расползались между кнопкой, API и очередью. В нашем коде слушатель добавляет TransitionBlocker с машинным кодом и понятным сообщением. Код подходит для аналитики и автоматического теста, сообщение — для интерфейса. У одной операции может быть несколько blockers, поэтому не стоит ограничиваться одним булевым флагом «доступно».

Роль в учебном Ticket передаётся прямо в конструктор для воспроизводимости. В рабочем приложении она должна приходить от аутентифицированного серверного контекста. Поле role в JSON пользователя не является доказательством полномочий. Кроме общей роли проверяйте принадлежность заявки организации, подразделению или ответственному сотруднику: право согласовывать вообще ещё не означает доступ к любой карточке.

$dispatcher->addListener('workflow.ticket.guard', function (GuardEvent $event) {
    $ticket = $event->getSubject();
    if ($event->getTransition()->getName() === 'approve'
        && $ticket->amount > 100000
        && $ticket->actorRole !== 'director') {
        $event->addTransitionBlocker(new TransitionBlocker(
            'Сумму больше 100 000 ₽ подтверждает director.',
            'amount_limit',
        ));
    }
});

Опыт 3. Возврат на доработку с обязательной причиной

Возврат — обычная часть процесса, и его стоит проектировать так же внимательно, как успешное согласование. В третьем опыте менеджер сначала пытается отклонить заявку без замечания. Guard оставляет её в submitted и возвращает reason_required. После добавления текста «Не приложена смета» действие становится допустимым. Затем reopen возвращает заявку в draft, а исправленная версия снова отправляется через submit.

Пользователю важен не только новый статус, но и следующее действие. Если карточка просто стала rejected, автору непонятно, что исправлять. Поэтому интерфейс должен показать замечание, автора решения и дату. В промышленном варианте причину записывают в неизменяемую историю действий, а не хранят единственным перезаписываемым полем объекта. В нашем минимальном Ticket поле reason служит входом текущей команды.

Не удаляйте прошлое решение при reopen. Журнал помогает восстановить, почему документ возвращали несколько раз и какие сведения были доступны при каждом согласовании. Если меняется существенная часть заявки после отправки, определите политику явно: запретить изменение, автоматически вернуть в draft или создать новую редакцию. Без этого статус «проверено» может относиться уже к другим данным.

Опыт 4. Две независимые проверки и общий финиш

В четвёртом опыте start создаёт legal_pending и tech_pending одновременно. Сначала согласует юрист, затем инженер. Между этими действиями попытка finish запрещена: одного завершённого направления недостаточно. После появления legal_done и tech_done объединяющий переход успешно переводит документ в ready. В мастерской особенно удобно смотреть схему и массив marking рядом.

Так можно моделировать договор с юридической и технической проверкой, закупку с бюджетом и безопасностью, публикацию с редактором и экспертом. Параллельность здесь означает независимые состояния процесса. Она не запускает два PHP-потока и не создаёт фоновые задачи автоматически. Исполнителей, очереди, уведомления и сроки назначает ваше приложение; Workflow проверяет допустимость результата.

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

new Transition('start', 'draft', ['legal_pending', 'tech_pending']);
new Transition('legal_ok', 'legal_pending', 'legal_done');
new Transition('tech_ok', 'tech_pending', 'tech_done');
new Transition('finish', ['legal_done', 'tech_done'], 'ready');

Опыт 5. События и ловушка побочного эффекта в guard

Пятый опыт начинается с двух вызовов can(submit). В журнале видны два guard, хотя заявка остаётся draft. Затем apply показывает последовательность жизненного цикла перехода. Эта небольшая проверка объясняет важное правило нашего приложения: guard только решает, разрешено ли действие. Если отправлять письмо или списывать деньги внутри него, простой просмотр карточки может вызвать нежелательный эффект.

События удобно использовать для технического наблюдения и подготовки доменных последствий. Но «получен completed» ещё не означает, что ваша последующая транзакция базы успешно зафиксирована. В нашем repository внешние HTTP-вызовы вообще не выполняются. Вместо них событие помещается в outbox в той же транзакции, что и новый статус. Следующий раздел показывает, зачем это нужно.

Список events в мастерской — память конкретного PHP-выполнения, а не готовый долговечный аудит. В production журнал требует хранения, связанного request ID, идентичности сотрудника и разумной политики доступа. Наблюдайте за числом вызовов guards: дорогой запрос к внешнему сервису в каждой проверке может замедлить даже построение списка кнопок. Разбор событий и блокировок есть в документации компонента.

$workflow->can($ticket, 'submit'); // guard
$workflow->can($ticket, 'submit'); // guard снова

$workflow->apply($ticket, 'submit');
// guard → leave → transition → enter → entered → completed → announce
// Возможны дополнительные guards при определении следующих действий.

Опыт 6. MethodMarkingStore и настоящее сохранение в SQLite

Название MarkingStore легко создаёт ложное ожидание: будто состояние уже сохранено в базе. В нашем проекте MethodMarkingStore вызывает методы getMarking и setMarking у PHP-объекта. Это изменение памяти текущего процесса. Чтобы следующий HTTP-запрос увидел результат, repository отдельно выполняет SQL UPDATE и фиксирует транзакцию.

Шестой опыт специально использует дочерние PHP-процессы. Один выполняет submit и завершает работу. Другой открывает тот же SQLite-файл и читает submitted с version=2. Так мы проверяем реальное сохранение, а не совпадение двух переменных в одном процессе. В ответе мастерской видны команда, код завершения, строка базы и число записей outbox/receipts.

В рабочем проекте путь к SQLite задавайте серверной конфигурацией и держите вне публичного каталога. Для интерфейса достаточно передавать идентификатор заявки; пользователь не должен выбирать имя файла базы. По работе с PDO и SQLite на сайте есть большой практический мануал: PDO и запросы, локальное приложение с SQLite и конкуренция и результаты операций.

$repository = new Repository('/var/lib/service-workflow/workflow.sqlite');
$row = $repository->read('REQ-1042');

$result = $repository->execute(
    'REQ-1042',
    (int) $row['version'],
    'submit',
    'request-2026-001',
);

Опыт 7. Два клиента и защита от потерянного обновления

Два сотрудника открыли карточку с version=1. Первый согласовал заявку, база стала approved/version=2. Второй отправил действие из старой карточки. Если обработчик просто перезапишет статус, решение первого можно потерять или выполнить побочный эффект дважды. В нашем опыте второй клиент получает version_conflict, а число событий outbox остаётся равным одному.

Запись воспроизводит контролируемое чередование: два независимых PHP-процесса сначала читают одинаковую версию, затем последовательно пытаются записать результат. Это проверка устаревшего снимка, а не нагрузочный тест одновременных писателей. Мы не делаем из неё выводов о максимальной скорости SQLite или поведении сотни параллельных запросов. Для таких вопросов нужны отдельные измерения с синхронным стартом клиентов.

В интерфейсе конфликт следует показать как изменение карточки другим участником. Загрузите актуальные данные и предложите сотруднику заново принять решение. Автоматически повторить approve с новой версией обычно неправильно: сумма, состав работ или замечания могли измениться. Серверная защита сохраняет данные, а понятное сообщение помогает пользователю безопасно продолжить работу.

UPDATE tickets
SET marking = :marking, version = version + 1
WHERE id = :id AND version = :expected_version;

-- rowCount() должен быть ровно 1.
-- Иначе транзакция откатывается, побочный эффект не создаётся.

Короткая транзакция: что входит в атомарную операцию

Наш repository начинает BEGIN IMMEDIATE, читает receipt и текущую строку, проверяет версию, создаёт доменный объект, применяет переход, обновляет запись и добавляет outbox. Затем выполняется COMMIT. При исключении — ROLLBACK. Такой порядок связывает решение и его внутренние последствия: не должно быть согласованной заявки без предусмотренного события или события от откатившейся операции.

Внутри транзакции нет отправки почты, загрузки файлов и HTTP-запросов. Пока внешний сервис думает, SQLite может удерживать запись и мешать другим пользователям. Держите критическую секцию короткой, а длительную работу передавайте отдельному обработчику. Таймаут ожидания блокировки помогает пережить короткий конфликт, но не исправляет транзакцию, которая занята минуту.

SQLite допускает много читателей, однако запись имеет свои ограничения; подробности описаны в документации транзакций SQLite. Для более нагруженного сервиса можно перенести repository на PostgreSQL или MySQL, сохранив идею версии и атомарного outbox. SQL и режим блокировок при этом нужно проверить заново. Название Workflow не предоставляет распределённую блокировку и не отменяет особенности выбранной базы.

Опыт 8. Повтор события и ключ идемпотентности

Клиент отправил запрос, сервер выполнил действие, но ответ потерялся по дороге. Повтор запроса в такой ситуации нормален. В восьмом опыте первое webhook-001 выполняет submit, а точный повтор возвращает сохранённый ответ с replayed=true. Версия не растёт второй раз, переход не исполняется повторно, outbox не получает дубликат. Клиент может понять, что его исходная операция уже завершилась.

Одного наличия ключа недостаточно. Мы храним fingerprint значимых параметров: идентификатора заявки, ожидаемой версии, перехода, роли и причины. Если тот же ключ приходит с другим действием, repository возвращает idempotency_key_reused. Иначе случайно переиспользованный ключ мог бы скрыть ошибку клиента и выдать ответ совершенно другой операции.

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

$first = $repository->execute('REQ-1042', 1, 'submit', 'webhook-001');
$retry = $repository->execute('REQ-1042', 1, 'submit', 'webhook-001');

// $first['replayed'] === false
// $retry['replayed'] === true
// Содержимое ticket совпадает, outbox содержит одну запись.

Outbox: сохранить событие ещё не значит доставить его

После успешного решения другим системам может понадобиться уведомление: создать задачу, отправить письмо, обновить отчёт. Опасный порядок — сначала вызвать внешний API, затем сохранить заявку. Если SQL не выполнится, внешняя система уже увидит действие, которого нет в нашей базе. Обратный порядок тоже имеет окно сбоя: статус сохранён, а процесс упал перед отправкой.

В примере таблица outbox записывается вместе со статусом. Это надёжная точка передачи работы будущему dispatcher, но самого отправителя в архиве нет. В отдельном процессе он должен выбирать необработанные события, учитывать попытки, задержку повторов, результат доставки и окончательные ошибки. Интерфейс администратора может показывать накопившуюся очередь и время последней попытки.

Даже с outbox возможен повтор: письмо ушло, но процесс завершился до записи подтверждения. Поэтому получателю нужен стабильный ID события и собственная дедупликация, если повтор имеет последствия. Не обещайте exactly-once только потому, что в таблице стоит UNIQUE. В нашем тесте доказано одно: повтор входной команды не создаёт вторую внутреннюю запись. Доставка во внешнюю систему — отдельная граница, которой этот стенд не притворяется.

Опыт 9. Карточка оператора и понятные причины запрета

Девятый опыт сравнивает карточку для operator, manager и director при одинаковом состоянии и сумме. Список allowed и blockers формирует Symfony. Браузер не вычисляет свои правила поверх ответа. Это хороший подход для административного интерфейса: сервер сообщает, какие действия доступны и почему остальные сейчас нельзя выполнить, а UI выбирает удобный способ объяснения.

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

Перед отправкой действия собирайте только нужные поля: ID, ожидаемую версию, код перехода, причину при необходимости и operation key. После ответа обновляйте всю карточку из серверных данных. Не рисуйте новый статус заранее как окончательный, если пользователь может получить guard или конфликт. При ожидании отключайте повторное нажатие, но сохраняйте возможность прочитать текущие данные и понять, что происходит.

Схема DOT и документация процесса для команды

В мастерской можно скачать DOT для текущего кадра. Его создаёт GraphvizDumper из фактического Definition и Marking на стороне PHP. Это удобно для обсуждения процесса с коллегами: граф можно хранить рядом с кодом, прикладывать к документации или преобразовать в изображение средствами Graphviz. Схема на самой странице упрощена для чтения на телефоне; активные блоки всё равно берутся из того же результата.

Документация особенно полезна при добавлении новых переходов. Допустим, понадобилось отменить уже согласованную заявку. На графе сразу видно, куда ведёт отмена, можно ли повторно отправить объект и какие проверки действуют. Затем те же изменения отражаются в тестах и интерфейсе. Если схема живёт отдельно от PHP и обновляется вручную, расхождение почти неизбежно.

Экспорт не описывает всё приложение: в нём нет политики доступа к конкретной организации, SQL-транзакций и правил доставки outbox. Эти границы стоит пояснять рядом текстом. Граф отвечает на вопрос о местах и переходах; README объясняет, как выполнить процесс и что сохраняется. Вместе они полезнее одного большого рисунка, который обещает решить всю архитектуру.

use Symfony\Component\Workflow\Dumper\GraphvizDumper;

$dot = (new GraphvizDumper())->dump(
    $workflow->getDefinition(),
    $workflow->getMarking($ticket),
);
file_put_contents('workflow.dot', $dot);

// При установленном Graphviz: dot -Tsvg workflow.dot -o workflow.svg

Запуск на Windows: PHP, Composer и локальная мастерская

Распакуйте ZIP в отдельный каталог и откройте PowerShell внутри symfony-workflow-project. Нужны PHP 8.2 или новее, Composer 2 и расширение PDO SQLite. Сначала проверьте php -v и список драйверов: установленный PHP для веб-сервера и PHP из PATH иногда используют разные настройки. Команда php –ini покажет, какой конфигурационный файл читает именно CLI.

После install запускаются тесты уже приложенной записи. Чтобы получить свою запись, выполните capture, build-public и снова test. Capture пересоздаёт только три именованные учебные базы в var; он не принимает произвольный путь и не подключается к внешнему серверу. Если вы вручную меняли эти демонстрационные файлы, сохраните их перед повторным запуском. В README точно перечислены затрагиваемые имена.

Адрес локальной мастерской — http://127.0.0.1:8511/. Если порт занят, замените его на свободный. Остановка сервера выполняется Ctrl+C в том же терминале. Открывать index.html двойным щелчком не стоит: загрузка JSON через file:// может блокироваться браузером. Локальный HTTP-сервер решает эту задачу, а PHP CLI отдельно исполняет сценарии.

php -v
php --ini
php -r "print_r(PDO::getAvailableDrivers());"
composer install --no-interaction --prefer-dist
composer check-platform-reqs
composer test
php -S 127.0.0.1:8511 -t public

Запуск на Linux: те же исходники и новый PHP-результат

На Ubuntu 24.04 LTS можно установить PHP CLI и SQLite-расширение из штатных пакетов, затем выполнить тот же Composer install. В другом дистрибутиве проверьте названия пакетов и фактическую версию PHP. Не обходите несовместимость флагом ignore-platform-reqs: успешная установка архива без нужного расширения не означает, что приложение будет работать.

Код и lock одинаковы для Windows и Linux. Разница в первую очередь касается установки среды, путей и синтаксиса оболочки. В примере ниже строка PHP заключена в одинарные кавычки, чтобы Bash не пытался интерпретировать её содержимое. Команды capture и test не зависят от PowerShell и работают обычным PHP CLI. В JSON новой записи появятся Linux, ваша версия PHP и версия SQLite.

Для знакомства можно запустить мастерскую на сервере только на loopback и открыть её через свой обычный SSH-туннель. Для публичного размещения используйте веб-сервер и отдельный document root, как в следующем разделе. Встроенный сервер PHP предназначен для разработки; эту границу прямо описывает PHP manual.

sudo apt update
sudo apt install php-cli php-sqlite3 php-mbstring php-xml php-curl php-zip unzip composer
php -v
php -r 'print_r(PDO::getAvailableDrivers());'
cd symfony-workflow-project
composer install --no-interaction --prefer-dist
composer check-platform-reqs
composer capture
composer build-public
composer test
php -S 127.0.0.1:8511 -t public

Linux/VPS: разместить мастерскую с HTTPS

Публичной replay-мастерской не нужен PHP backend. Сценарии можно выполнить на машине разработки или CI, после чего разместить только public и архив исходников. Это небольшой статический набор: HTML, CSS, JavaScript, JSON, лицензии и текстовые копии PHP. Посетители изучают процесс, но не получают возможность менять общую базу или запускать произвольные переходы на вашем сервере.

В примере Caddy обслуживает каталог /srv/workflow/public. Замените учебный домен своим, настройте DNS и доступность портов 80/443 для автоматического HTTPS. Перед применением конфигурации выполните caddy validate, затем штатный reload сервиса. Каталоги vendor, bin, src и SQLite остаются за пределами document root. Не рассчитывайте, что случайный файл .htaccess обязательно будет прочитан вашим веб-сервером.

Ссылка на ZIP в мастерской относительная: ../symfony-workflow-project.zip. При самостоятельном размещении положите архив по соответствующему публичному адресу или измените ссылку. После загрузки проверьте страницу, JSON, скачивание и текст исходника с обычного браузера. Развёртывание файлов ещё не подтверждает, что ссылки корректны после смены каталога или домена.

workflow.example.com {
    root * /srv/workflow/public
    encode zstd gzip
    file_server
}

# Проверка перед reload:
# caddy validate --config /etc/caddy/Caddyfile

Встроить Workflow в рабочее PHP-приложение на сервере

Для настоящего сервиса нужен HTTP-слой вокруг repository: аутентификация, доступ к конкретной заявке, разбор входа, ограничения тела, CSRF для cookie-сессии и понятный формат ошибок. Наш архив остаётся CLI-стендом и не выдаёт себя за готовое публичное API. Это позволяет изучить доменную часть отдельно и затем подключить её к вашему существующему контроллеру, фреймворку или очереди.

На VPS запускайте PHP-FPM от отдельного пользователя приложения и отдавайте через веб-сервер только public. Постоянную SQLite-базу держите, например, в /var/lib/service-workflow. Пользователю PHP нужны права не только на файл, но и на каталог для служебного журнала SQLite. Не делайте каталог общедоступным на запись. Команды создания пользователя и настройки FPM зависят от вашей системы; не меняйте владельцев чужого приложения вслепую.

При деплое устанавливайте зависимости из проверенного lock, выполняйте тесты и применяйте миграции отдельно. Не запускайте capture против production: он предназначен для фиксированных демонстрационных баз. Для изменения схемы рабочего хранилища используйте обычный механизм миграций и резервного копирования. Репозиторий принимает путь конструктором, поэтому серверная конфигурация может задавать его без изменения самой логики переходов.

// Пример границы приложения, не готовый HTTP endpoint:
$actor = $authenticatedUser;
$ticketId = $validatedCommand->ticketId;
$authorization->assertCanAccess($actor, $ticketId);

$result = $repository->execute(
    $ticketId,
    $validatedCommand->expectedVersion,
    $validatedCommand->transition,
    $validatedCommand->operationKey,
    $actor->trustedRole,
    $validatedCommand->reason,
);

Тесты: проверять правила, откат и повтор операции

В tests/run.php проверяются тридцать условий. Важны не только успешные submit и approve: запрещённый переход должен оставить marking прежним; отсутствие причины должно блокировать возврат; неполная параллельная проверка не должна пропускать finish. Тест guard отдельно проверяет две одновременно действующие причины и их изменение при смене роли.

Для repository тест создаёт собственную временную SQLite-базу. После запрещённого действия проверяет и строку, и receipts, и outbox: частичная запись была бы ошибкой, даже если HTTP сообщил отказ. Затем два соединения читают одну версию, первый выполняет действие, второй получает конфликт. Повтор с тем же ключом должен вернуть точный исходный результат, а изменение payload под старым ключом — отказ.

Записанные сценарии дополнительно связаны с исходниками SHA-256. Если PHP изменён, а recording.json оставлен старым, тест обнаружит расхождение. После осознанной правки выполните capture и проверьте новые результаты. Такой механизм не заменяет рецензию кода, но защищает учебный материал от распространённой ошибки: текст рассказывает об одной реализации, а интерфейс показывает давно записанную другую.

composer test
composer validate --strict
composer check-platform-reqs
composer audit

# После изменения PHP:
composer capture
composer build-public
composer test

Частые проблемы: драйвер, блокировка, версия и неверный граф

Ошибка could not find driver обычно означает, что выбранный PHP не загрузил pdo_sqlite. Сравните php –ini, php -m и конфигурацию FPM. Ошибка Cannot apply transition требует другой диагностики: посмотрите текущее marking и buildTransitionBlockerList. Не отключайте guard ради исчезновения сообщения — сначала выясните, почему предметное правило не выполняется.

Database is locked заставляет проверить длительность транзакций, второго писателя и права на каталог. Не увеличивайте таймаут бесконечно, если внутри транзакции выполняется медленный HTTP-запрос. Version conflict означает, что данные изменились после чтения: покажите актуальную карточку и попросите принять решение заново. Эти ошибки относятся к разным слоям, поэтому одно общее «попробуйте позже» часто бесполезно.

Если finish неожиданно разрешается раньше, проверьте тип процесса и список входных мест. Если статус исчезает между запросами, убедитесь, что результат реально сохранён, а не остался только в объекте. Если схема на странице не меняется после правки PHP, пересоздайте запись. И отдельно проверьте кэш CDN или браузера: новый код и старый JSON не должны смешиваться в одной версии публикации.

Как развивать пример: сроки, версии процесса и история

После базового внедрения обычно появляются сроки выполнения и эскалации. Их лучше описывать как события приложения: планировщик находит просроченные заявки и вызывает предусмотренное действие с доверенным контекстом. Workflow проверяет переход, но сам не просыпается по таймеру. Для задачи «через три дня напомнить руководителю» потребуется планировщик или очередь, а не только новое место на графе.

Ещё один вопрос — изменение процесса при уже существующих заявках. Если вы удалили submitted из определения, старые записи с таким marking не станут корректными автоматически. Планируйте миграцию данных или версионирование процесса. Для договора, прошедшего согласование по старым правилам, иногда важно сохранить именно старую версию схемы и набор решений. Это предметное требование стоит обсудить до деплоя.

Историю действий полезно хранить отдельно от текущего статуса: кто, когда, какой переход, из какого marking, в какое, с какой причиной и correlation ID. Объём чувствительных данных ограничивайте необходимым. Текущая карточка отвечает на вопрос «где мы сейчас», история — «как сюда пришли». Такой подход помогает и пользователю, и поддержке, и разработчику, который расследует редкий конфликт.

Исходники, структура проекта и дальнейшее чтение

В ZIP входят Ticket и Review, определения процессов, repository, capture, дочерняя CLI-команда, тесты, статическая мастерская, README и lock. Каталог vendor не упакован: Composer установит зависимости по lock. Учебные базы также не входят в архив и создаются локально. Готовая recording.json позволяет сразу открыть мастерскую через HTTP, а capture даёт повторить настоящее исполнение в вашей среде.

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

Для продолжения используйте исходный код Symfony Workflow, официальную документацию компонента и PHP manual по подготовленным запросам PDO. На нашем сайте рядом полезны Composer, миграции Phinx и SQLite на практике.