Saga Lara Flow: Durable Workflows and Compensating Transactions on Laravel Queues
Laravel Composer Pacakge #Laravel #Workflows #Saga Pattern #Queues #Compensating Transactions #PHP Package

Saga Lara Flow: Durable Workflows and Compensating Transactions on Laravel Queues

4 min read Mohamed Said Mohamed Said

What Is Saga Lara Flow?

Saga Lara Flow is a Laravel package by Andriy Karpishyn that lets you model long-running business processes — charge a card, reserve stock, book a shipment — as a single handle() method on top of Laravel queues. No job chaining, no hand-rolled state machines.

The engine records every completed step to the database. When a worker picks up a workflow, it re-executes handle() from the top, but $this->action() intercepts each call: already-completed steps return their stored result instantly, and execution resumes only at the first unfinished step. If any step throws, registered compensations fire in reverse order.

Core Features at a Glance

  • Workflows as plain methods — sequential $this->action() calls with no job chaining
  • Compensating transactions — register an undo action per step with compensateWith()
  • Signals — suspend a run until external input arrives, with optional timeout
  • Parallel blocks — dispatch independent actions concurrently and collect results
  • Child workflows — nest workflows with a configurable close policy
  • Side effect recording — wrap non-deterministic values so replays stay deterministic
  • Tag-based querying — find runs by workflow class, tag, and status
  • Artisan commands — list, inspect, signal, cancel, prune, and monitor runs

Defining Workflows and Actions

A workflow extends Workflow and calls action classes through $this->action(). Actions are resolved from the container, so dependencies are injected automatically:

use DiscoveryUkraine\SagaLaraFlow\Workflow;

class ProvisionAccountWorkflow extends Workflow
{
    public function handle(string $email): array
    {
        $tenantId = $this->action(CreateTenant::class, $email)->run();
        $this->action(SendWelcomeEmail::class, $email)->run();
        return ['tenant' => $tenantId];
    }
}

Runs are started via the SagaFlow facade. runSync() drives every step in-process, making it ideal for tests:

$run = SagaFlow::create(ProvisionAccountWorkflow::class)
    ->withArguments('jane@example.com')
    ->runSync();

$this->assertTrue($run->isCompleted());

Non-deterministic values like UUIDs must be wrapped in sideEffect() so replays always see the original result:

$reference = $this->sideEffect('reference', fn () => (string) Str::uuid());

Compensating Failed Transactions

Each step can register an undo action. If a later step fails, compensations fire in reverse order:

public function handle(string $orderId): void
{
    $this->action(ChargeCard::class, $orderId)
        ->compensateWith(RefundCard::class, $orderId)
        ->run();

    $this->action(ReserveStock::class, $orderId)
        ->compensateWith(ReleaseStock::class, $orderId)
        ->run();

    // If this throws, ReleaseStock runs first, then RefundCard.
    $this->action(ShipOrder::class, $orderId)->run();
}

For grouped rollbacks, $this->saga() supports onCompensationFailure() and compensateInParallel() to control whether a failed undo aborts the rollback and whether undos run concurrently.

Signals: Waiting on External Input

$this->signal() suspends a run until external code delivers the named signal. timeoutAfter() adds a deadline:

try {
    $decision = $this->signal('approval')
        ->timeoutAfter(now()->addDay())
        ->wait();
} catch (AwaitSignalTimeoutException $e) {
    $this->action(AutoReject::class)->run();
}

Deliver the signal from anywhere in your application:

SagaFlow::loadFlow($runId)->signal('approval', ['approved' => true]);

Tag-based querying lets you locate the right run without storing its ID:

SagaFlow::query()
    ->whereWorkflow(ProvisionCompanyWorkflow::class)
    ->whereTag('company', $companyId)
    ->signalable()
    ->handles()
    ->first()
    ?->signal('owner-synced');

Concurrency and Child Workflows

Independent actions run in parallel with $this->parallel():

[$pricing, $inventory, $reviews] = $this->parallel()
    ->action(FetchPricing::class, $sku)
    ->action(FetchInventory::class, $sku)
    ->action(FetchReviews::class, $sku)
    ->run();

Child workflows are invoked with $this->child(), and a ChildClosePolicy controls what happens when the parent finishes.

Installation

The package requires PHP 8.5 and Laravel 13:

composer require discovery-ukraine/saga-lara-flow
php artisan migrate
php artisan vendor:publish --tag="saga-lara-flow-config"

Register the expiration monitor with the Laravel scheduler:

Schedule::command('saga-flow:monitor')->everyMinute();

Key Takeaways

  • Write multi-step distributed processes as a single readable handle() method
  • Automatic replay skips already-completed steps without re-running side effects
  • Compensating transactions roll back completed steps in reverse order on failure
  • Signals pause execution until a human or external system responds
  • Parallel blocks, optional steps, child workflows, and versioning cover advanced use cases
  • runSync() makes the whole workflow testable without a real queue

Full documentation is available at sagalaraflow.dev. Read the original announcement at Laravel News.

Found this useful?

Frequently Asked Questions

3 questions
Q01 How does Saga Lara Flow avoid re-running completed steps when a workflow is replayed?
The package records each completed step and its result to the database. On replay, `$this->action()` intercepts every call and returns the stored result for already-completed steps without executing the action class again. Execution only resumes at the first step that has not yet finished.
Q02 What happens if a step in the middle of a workflow fails?
When a step throws an exception, the engine triggers the compensation actions registered for all previously completed steps, running them in reverse order. You can register an undo action class or a closure per step using `compensateWith()`, and group compensations with `$this->saga()` for parallel or fault-tolerant rollback behavior.
Q03 Can a workflow pause and wait for an external event like a human approval?
Yes. `$this->signal()` suspends the workflow run and releases the worker. When external code calls `SagaFlow::loadFlow($runId)->signal('approval', $data)`, the run resumes from where it left off. You can also chain `timeoutAfter()` to set a deadline and catch `AwaitSignalTimeoutException` if it expires.

Continue reading

More Articles

View all