Laravel Packages

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

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

Saga Lara Flow is a Laravel package by Andriy Karpishyn that lets you write long-running business processes as single PHP methods on top of Laravel queues. Charge the card, reserve the stock, book the shipment — one line after another in a handle() method, with no job chaining and no state machine.

What makes that work is that the package records every step to the database as it finishes. When a workflow resumes or gets picked up by a worker, the engine re-executes the handle() method, but $this->action() intercepts each call: if a step already completed, it returns the recorded result without running the action class again. Once replay reaches unexecuted code, normal execution resumes. When a step throws an exception, the engine triggers the compensation logic registered for previous steps in reverse order.

Here's what the package gives you:

  • Workflows as plain methods: a handle() method calls actions in sequence, with suspension between steps handled by exception-based control flow rather than chaining jobs.
  • Compensations: register an undo action or closure per step with compensateWith(), rolling back completed steps if a later one fails.
  • Signals: $this->signal() suspends a run until external code delivers data to it, with optional timeout support.
  • Parallel blocks: dispatch several actions concurrently and collect their return values as an array.
  • Child workflows: run a nested workflow with a close policy controlling what happens to it when the parent finishes.
  • Side effect recording: wrap non-deterministic values such as UUIDs and timestamps so replays see the original result.
  • Tag-based querying: attach key/value tags at creation or from inside a run, then query by workflow class, tag, and status.
  • Artisan commands: list runs, inspect state, deliver signals, cancel executions, monitor expirations, and prune old records.

Workflows and Actions

A workflow extends the package's Workflow class and calls actions through $this->action(). Actions are separate classes resolved from the container, so their dependencies are injected alongside the arguments you pass:

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];
}
}
use DiscoveryUkraine\SagaLaraFlow\Action;
 
class CreateTenant extends Action
{
public function handle(TenantRepository $tenants, string $email): string
{
return $tenants->provision($email)->id;
}
}

Actions carry their own queue settings. A $tries property controls retry attempts and $timeout caps each attempt, while expiresAt() sets a wall-clock deadline for an individual step. When retries are exhausted, the workflow method receives an ActionFailedException it can catch and respond to, and a passed deadline surfaces as a FlowExpiredException.

Runs are started through the SagaFlow facade. run() queues the workflow and returns a pending run immediately, while runSync() drives every step in-process and returns the completed run, making it easy to assert outcomes in tests:

use DiscoveryUkraine\SagaLaraFlow\Facades\SagaFlow;
 
$run = SagaFlow::create(ProvisionAccountWorkflow::class)
->withArguments('jane@example.com')
->runSync();
 
$this->assertTrue($run->isCompleted());
$this->assertEquals('tenant-123', $run->result()['tenant']);

Replay only works if each step yields the same value it yielded the first time around, so anything non-deterministic has to be recorded on first execution. Wrap it in sideEffect() and the stored value comes back on every later pass:

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

Compensating Failed Transactions

The saga half of the package is the rollback behavior. Each step can register the action that undoes it, and those undos fire in reverse order when a later step fails:

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, then RefundCard.
$this->action(ShipOrder::class, $orderId)->run();
}

For smaller compensations, you can pass a closure instead of an action class. When a group of steps should roll back as a unit, $this->saga() builds an explicit block with two extra controls: onCompensationFailure() decides whether a failed undo aborts the rollback or lets it keep going, and compensateInParallel() runs the group's undos concurrently instead of one at a time.

use DiscoveryUkraine\SagaLaraFlow\Enums\CompensationFailurePolicy;
 
$this->saga()
->onCompensationFailure(CompensationFailurePolicy::Continue)
->compensateInParallel()
->step(ChargeCard::class, $orderId)->compensateWith(RefundCard::class, $orderId)
->step(ReserveStock::class, $orderId)->compensateWith(ReleaseStock::class, $orderId)
->run();

Waiting on External Input

Signals handle the case where a process pauses for a human approval or a third-party callback. $this->signal() suspends the run and releases the worker until something delivers the named signal. Calling wait() resumes execution once received, and chaining timeoutAfter() adds a deadline:

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

Delivery happens from anywhere in your application through a run handle. There is also a signalIfRunning() variant that returns false instead of throwing when the run has already finished or been cancelled:

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

Tags let you find the right run without having stored its ID anywhere. Attach them at creation with withTags() or from inside handle() with $this->tag(), then filter on workflow class, tag, and status:

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

The same builder is useful for operational checks. Scopes like running(), waiting(), failed(), and before() return either FlowRun models or handles you can act on, so finding every run stuck in a waiting state for more than an hour takes a single query.

Concurrency, Optional Steps, and Nesting

Independent work goes in a parallel block, which returns the action results as a list you can destructure. The default failFast() policy cancels the block on the first failure:

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

A step that should not take down the whole run gets continueOnFailure() plus a fallback value, with optionalAction() as the shorthand:

$score = $this->optionalAction(FetchRiskScore::class, $orderId)
->fallbackValueOnFail(0)
->run();

Workflows can also invoke child workflows with $this->child(), passing a ChildClosePolicy that specifies whether the child workflow should be cancelled or left running when the parent workflow completes:

use DiscoveryUkraine\SagaLaraFlow\Enums\ChildClosePolicy;
 
$result = $this->child(ConfigureSubdomainWorkflow::class, $domain)
->onParentClose(ChildClosePolicy::Terminate)
->run();

A workflow started today might still be running next month, which is where versioning comes in. Passing version('v2') when creating a run pins it to a workflow definition, so in-flight runs keep replaying against the code they started with while new runs pick up the updated class. Deadlines need something watching the clock to notice when they pass, so the package ships a saga-flow:monitor command to register with the Laravel scheduler:

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

Installation

The package requires PHP 8.5 and Laravel 13, and is released under the MIT license:

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

The published config file covers the database connection and queue used for workflow state, lock behavior, and tenancy hooks. For multi-tenant applications, supply capture and restore closures so a run resumes on a worker under the same tenant context it started in.

The package includes Artisan commands for scaffolding and managing workflow executions:

  • make:workflow and make:action: Stub out new workflow and action classes.
  • saga-flow:list: Inspect active, waiting, and failed executions.
  • saga-flow:signal: Deliver signals directly to a workflow from the CLI.
  • saga-flow:prune: Clean up completed and cancelled run records.

Full documentation, including sections on expiration monitoring, testing, and multi-tenancy, is at sagalaraflow.dev. You can learn more about this package, get full installation instructions, and view the source code on the saga-lara-flow GitHub repository.

Yannick Lyn Fatt photo

Staff Writer at Laravel News and Full stack web developer.

Sponsored

laravelcloud logo
Laravel Cloud

Easily create and manage your servers and deploy your Laravel applications in seconds.

Visit Laravel Cloud

The latest

View all →
Validate and Convert HEIC Images in Laravel image

Validate and Convert HEIC Images in Laravel

Read article
Reject Unexpected Array Keys with Laravel Validation image

Reject Unexpected Array Keys with Laravel Validation

Read article
Major performance improvements & security patches for Filament v4.12 and v5.7! image

Major performance improvements & security patches for Filament v4.12 and v5.7!

Read article
Laravel Boost Project Rules: Teach Agents Your Conventions image

Laravel Boost Project Rules: Teach Agents Your Conventions

Read article
Extract an Image's Dominant Color in Laravel image

Extract an Image's Dominant Color in Laravel

Read article
Image Dominant Color and HEIC Support in Laravel 13.24 image

Image Dominant Color and HEIC Support in Laravel 13.24

Read article