Побудова workflow для виконання замовлень у Laravel

Перекладено ШІ 0 JustSteveKing 05 серпня, 2026

Складні бізнес-процеси в Laravel часто перетворюються на хаос із розрізнених Job та нескінченних статусів у базі даних. Розбираємося, як за допомогою workflow engine побудувати прозору та стійку до помилок логіку обробки замовлень.

Кожен додаток, над яким я працював, рано чи пізно обростає процесами. Не просто фічами, а саме процесами. Це щось, що запускається, приймає оплату, чекає на відповідь від стороннього сервісу, іноді потребує уваги людини й завершується через кілька годин або навіть днів. Checkout — найочевидніший приклад, але онбординг, повернення коштів, KYC, даунгрейд підписки та підписання документів мають схожу структуру.

Зазвичай ми будуємо такі системи випадково: Job знімає гроші з картки, listener реагує на payment webhook, а scheduled command щогодини сканує таблицю в пошуках рядків у певному стані. Потім з’являється nullable колонка reviewed_at, далі — enum status з дев’ятьма варіантами та коментар над ним, що пояснює дозволені переходи. Фактично ви створили state machine, але вона розмазана між п’ятьма файлами та базою даних, і ніхто не може сказати, де саме перебуває замовлення, не прочитавши весь код.

Саме цю проблему вирішує workflow engine. Замість того, щоб показувати розрізнені фрагменти коду, ми побудуємо реальний процес виконання замовлення (order fulfilment) для невеликого магазину, де кожна функція з’являтиметься саме тоді, коли вона дійсно потрібна проекту.

До кінця статті ми розберемо всі частини пакета: steps, context, очікування signals від webhooks, timeouts, retries із backoff, розгалуження (branching), human approval, затримки (delayed follow up), компенсацію saga, events та консольні команди. Не тому, що я шукав привід їх використати, а тому, що справжньому замовленню вони дійсно необхідні.

Ось схема того, що ми будуємо:

Резерв стоку -> Оплата -> Risk gate
                            |         \
                      (low risk)   (high risk)
                            |            \
                            |          Manual review  ->  Decision
                            |            /        \
                            |      (approved)   (rejected -> refund + release)
                            v          /
                        Пакування  ->  Відправка  ->  Follow up через 2 дні

Дві стрілки на схемі означають очікування. Оплата чекає на payment webhook. Manual review чекає на рішення людини. Це очікування — головна причина, чому workflow engine тут необхідний.

Налаштування

composer require juststeveking/workflow-engine
php artisan migrate

Service provider знайдеться автоматично, а міграції вже є в пакеті. Це все налаштування. У базі з’являться дві таблиці: workflow_instances (по одному рядку на замовлення) та workflow_signals (лог усіх вхідних webhooks та рішень).

Важливий момент: engine просуває інстанси вперед за допомогою queued jobs, тому у вас повинен бути запущений worker.

php artisan queue:work

Без worker нічого не рухатиметься. Тримайте цей термінал відкритим.

Workflow — це список steps

Визначимо структуру workflow перед тим, як писати кроки. Workflow definition — це просто назва та впорядкований список класів step.

use JustSteveKing\WorkflowEngine\Contracts\WorkflowDefinitionContract;

final class FulfilOrderWorkflow implements WorkflowDefinitionContract
{
    public static function name(): string
    {
        return 'fulfil_order';
    }

    public function steps(): array
    {
        return [
            ReserveStockStep::class,
            ChargeCustomerStep::class,
            RiskGateStep::class,
            ManualReviewStep::class,
            ReviewDecisionStep::class,
            PackOrderStep::class,
            ShipOrderStep::class,
            DeliveryFollowUpStep::class,
        ];
    }
}

Порядок у масиві має велике значення. Поки що сприймайте його як "happy path", що виконується зверху вниз.

Зареєструйте definition, зазвичай у методі boot() вашого service provider:

use JustSteveKing\WorkflowEngine\Domain\WorkflowRegistry;

public function boot(): void
{
    $this->app->make(WorkflowRegistry::class)->register(FulfilOrderWorkflow::class);
}

Перший step та його суть

Кожен step — це невеликий клас, що реалізує WorkflowStepContract. Він робить одну справу в методі execute() і повертає StepResult, який каже двигуну, що робити далі. Також він визначає, скільки часу готовий чекати на signal (timeoutSeconds()) і скільки спроб має до того, як engine здасться (maxAttempts()).

Наш перший крок — резервування товару. Це дія з побічним ефектом, який може знадобитися скасувати. Якщо замовлення зірветься, ми захочемо повернути товар на склад. Тому цей крок також реалізує CompensatingStep.

use JustSteveKing\WorkflowEngine\Contracts\CompensatingStep;
use JustSteveKing\WorkflowEngine\Contracts\WorkflowStepContract;
use JustSteveKing\WorkflowEngine\Domain\StepResult;
use JustSteveKing\WorkflowEngine\Domain\WorkflowContext;

final class ReserveStockStep implements WorkflowStepContract, CompensatingStep
{
    public function __construct(
        private readonly Inventory $inventory,
    ) {}

    public function execute(WorkflowContext $context): StepResult
    {
        $this->inventory->reserve(
            orderId: $context->get('order_id'),
            lines: $context->get('lines'),
        );

        return StepResult::complete(['stock_reserved' => true]);
    }

    public function compensate(WorkflowContext $context): void
    {
        $this->inventory->release($context->get('order_id'));
    }

    public function timeoutSeconds(): ?int
    {
        return null; // нема чого чекати, тому без таймауту
    }

    public function maxAttempts(): int
    {
        return 1;
    }
}

Кілька важливих моментів:

Steps резолвляться з container. Inventory у конструкторі буде ін’єктовано автоматично. Це звичайні класи, які легко тестувати ізольовано.

WorkflowContext — це імутабельний набір даних, що передається від кроку до кроку. Ви читаєте з нього через get(), але ніколи не пишете напряму. Натомість StepResult::complete([...]) мержить масив у context для всіх наступних кроків. Імутабельність тут критична: якби step міг мутувати context і впасти з помилкою, дані в базі та в пам’яті розійшлися б.

StepResult::complete() означає "крок виконано, йдемо далі". Це один із п’яти можливих результатів.

Запуск замовлення

Коли definition зареєстровано і перший step готовий, можна запускати інстанс. Це те, що ви викличете у своєму checkout controller після створення замовлення:

use JustSteveKing\WorkflowEngine\Domain\WorkflowEngine;

$instance = app(WorkflowEngine::class)->start(
    workflowName: 'fulfil_order',
    aggregateId: (string) $order->id,
    aggregateType: 'order',
    initialContext: [
        'order_id' => $order->id,
        'customer_id' => $order->customer_id,
        'lines' => $order->lines->toArray(),
        'total_in_cents' => $order->total_in_cents,
    ],
);

Пара aggregateId та aggregateType — одна з найкорисніших ідей пакета. Ми прив’язуємо цей workflow до замовлення №42. Коли Stripe пізніше надішле нам webhook про успішну оплату, він нічого не знатиме про ID нашого workflow. Він знатиме лише номер замовлення. Aggregate — це наш спосіб знайти потрібний процес.

Метод start() зберігає інстанс у базу. Поки що нічого не запустилося. У продакшні engine сам викличе advance() через чергу, тому вам рідко доведеться робити це вручну.

Оплата: де починається магія

Цей step демонструє більшість можливостей двигуна. Оплата карткою — це не резерв замовлення. Виклик шлюзу може тимчасово впасти (потрібні retries). Підтвердження приходить пізніше через webhook (потрібна пауза). А якщо замовлення скасують, клієнту треба повернути гроші (compensation).

use JustSteveKing\WorkflowEngine\Contracts\CompensatingStep;
use JustSteveKing\WorkflowEngine\Contracts\HasRetryBackoff;
use JustSteveKing\WorkflowEngine\Contracts\WorkflowStepContract;
use JustSteveKing\WorkflowEngine\Domain\StepResult;
use JustSteveKing\WorkflowEngine\Domain\WorkflowContext;

final class ChargeCustomerStep implements WorkflowStepContract, CompensatingStep, HasRetryBackoff
{
    public function __construct(
        private readonly PaymentGateway $gateway,
    ) {}

    public function execute(WorkflowContext $context): StepResult
    {
        $intent = $this->gateway->createPaymentIntent(
            customer: $context->get('customer_id'),
            amount: $context->get('total_in_cents'),
        );

        return StepResult::await('payment_captured', [
            'payment_intent_id' => $intent->id,
        ]);
    }

    public function compensate(WorkflowContext $context): void
    {
        $this->gateway->refund($context->get('payment_intent_id'));
    }

    public function timeoutSeconds(): ?int
    {
        return 900; // чекаємо 15 хвилин, інакше замовлення вважається проваленим
    }

    public function maxAttempts(): int
    {
        return 3; // глюк шлюзу не повинен "вбивати" замовлення
    }

    public function retryBackoff(int $attempt): int
    {
        return 10 * $attempt; // 10с, потім 20с
    }
}

Що тут відбувається:

Очікування signal. StepResult::await('payment_captured', [...]) не блокує worker. Інстанс "паркується" в базі зі статусом awaiting і просто чекає. Жодної зайнятої пам’яті чи процесора. Коли прийде webhook, ми надішлемо signal, і інстанс "прокинеться".

Timeout. Якщо за 15 хвилин signal не прийде, engine автоматично зафейлить замовлення. Краса в тому, що якщо webhook прийде на другій хвилині, застарілий таймаут на 15-й хвилині буде просто проігнорований.

Retries з backoff. Якщо createPaymentIntent() викине помилку, engine спробує ще раз. retryBackoff() дозволяє рознести спроби в часі, щоб не "задовбувати" шлюз, який і так лежить.

Пробудження через webhook

Ось де стає у пригоді aggregate. Контролер Stripe отримує подію. Він знає ID замовлення (ви додали його в metadata при створенні платежу). Він запитує repository: "які інстанси для цього замовлення чекають на payment_captured?" — і надсилає їм signal.

$awaiting = $repository->findAwaitingSignal(
    signal: 'payment_captured',
    aggregateId: (string) $event->metadata['order_id'],
    aggregateType: 'order',
);

foreach ($awaiting as $instance) {
    $engine->signal(
        instanceId: $instance->id,
        signal: 'payment_captured',
        signalData: ['payment_id' => $event->payment_id, 'risk_score' => $event->risk_score],
        deliveredBy: 'stripe_webhook',
    );
}

Важливо: якщо webhook прийде швидше, ніж крок встигне "запакуватися" в базі, engine закешує його і використає в момент паркування. Ви не програєте в "race condition".

Розгалуження (branching)

Більшість замовлень ок, але ризиковані має перевірити людина. Для цього є goto.

final class RiskGateStep implements WorkflowStepContract
{
    public function execute(WorkflowContext $context): StepResult
    {
        $risky = $context->get('risk_score', 0) >= 75;

        return $risky
            ? StepResult::complete() // переходимо до наступного кроку (ManualReview)
            : StepResult::goto(PackOrderStep::class); // перестрибуємо перевірку
    }
    // ...
}

Логіка проста: якщо замовлення безпечне, ми стрибаємо через кроки перевірки прямо до пакування. Якщо ризиковане — просто йдемо за списком до ManualReview.

Людина в процесі

Крок ручної перевірки механічно такий самий, як і очікування Stripe. Відмінність лише в тому, що signal надсилає адміністратор, натиснувши кнопку в адмінпанелі.

final class ManualReviewStep implements WorkflowStepContract
{
    public function execute(WorkflowContext $context): StepResult
    {
        $this->reviews->open($context->get('order_id'));
        return StepResult::await('review_decision');
    }
    // ...
}

Після отримання рішення ми ставимо маленький ReviewDecisionStep, який або дозволяє йти далі (complete()), або зупиняє все (fail()).

Пакування, відправка та затримка

Для затримок існує п’ятий результат — sleep. Наприклад, ми хочемо надіслати email клієнту через два дні після відправки замовлення. Замість того, щоб щогодини сканувати базу крон-табом, ми просто кажемо workflow "заснути".

final class ShipOrderStep implements WorkflowStepContract
{
    public function execute(WorkflowContext $context): StepResult
    {
        // ...відправка...
        return StepResult::sleep(60 * 60 * 24 * 2, ['shipped_at' => now()->toIso8601String()]);
    }
    // ...
}

Через два дні інстанс прокинеться і виконає наступний крок. Весь цей час він просто лежить у базі й нічого не споживає.

Коли все ламається: compensation

Якщо замовлення відхилено на етапі ReviewDecisionStep через StepResult::fail(), у нас проблема: товар уже зарезервований, а гроші зняті. Ви не можете відкотити оплату через базу даних. Тут вмикається saga pattern.

Engine дивиться на кроки, що вже завершені, знаходить ті, що реалізують CompensatingStep, і запускає їхні методи compensate() у зворотному порядку. Гроші повертаються, товар розблоковується. Ви не налаштовуєте це вручну — engine робить усе сам у момент провалу.

Моніторинг

Кожна дія двигуна викликає event. Ви можете підписатися на них для Slack-сповіщень або дашбордів:

Event::listen(WorkflowFailed::class, function (WorkflowFailed $event): void {
    Log::warning("Workflow {$event->instanceId} failed: {$event->reason}");
});

Робота через термінал

Якщо замовлення "застрягло", команда workflow:show покаже все: статус, поточний крок, весь context та лог сигналів.

php artisan workflow:show 42
php artisan workflow:retry 42  # спробувати ще раз після виправлення помилки

Також додайте одну команду в scheduler:

Schedule::command('workflow:tick')->everyMinute();

Це "страховка": вона знаходить інстанси, що мали прокинутися після sleep, але не зробили цього через збій у чергах.

Безпечний деплой

Що буде, якщо ви додасте новий крок у workflow, а в цей час 200 замовлень уже в процесі? Нічого страшного. При запуску engine робить "знімок" списку кроків. Ті, що вже запущені, допрацюють за старою логікою, а нові — за новою.

Підсумок

Workflow engine змінює спосіб мислення. Ви перестаєте думати "який listener чи cron мені потрібен" і починаєте думати "які тут кроки і де ми чекаємо?". Як тільки ви дасте чесну відповідь на ці питання, процес фактично напише себе сам. Жахливі enum-статуси та сканування таблиць щогодини залишаться в минулому.

Популярні

Інше, що варто прочитати

14 Оновлено 26 червня, 2026

Локальні моделі та їх скоупи в Laravel за допомогою атрибута Scope

В Laravel 12 ми отримали можливість використовувати новий підхід для визначення локальних скоупів у моделях Eloquent. Дізнайтеся, як новий атрибут #[Scope] спрощує цей процес і зберігає ваші назви методів незмінними

10 Оновлено 26 червня, 2026

Остаточний посібник з вебхуків у Laravel

Сучасні веб-додатки вимагають миттєвого обміну інформацією, і вебхуки стають незамінними помічниками у цьому процесі. Пориньте у світ вебхуків у Laravel і дізнайтеся, як легко реалізувати цю технологію у своїх проєктах, забезпечуючи безпеку та продуктивність

60 Оновлено 26 червня, 2026

Усе, що нам відомо про Livewire 4

Нова версія Livewire 4, представленої Келебом Порзіо на Laracon US 2025, обіцяє значні покращення у швидкості та організації компонентів. Які з інноваційних функцій підкорять ваше серце? Читайте далі, щоб дізнатися більше про те, як Livewire 4 полегшить вашу роботу