waqas-badar / laravel-saga
Saga Pattern orchestrator for Laravel distributed transactions and workflows.
Requires
- php: ^8.2
- illuminate/bus: ^10.0 || ^11.0 || ^12.0 || ^13.0
- illuminate/console: ^10.0 || ^11.0 || ^12.0 || ^13.0
- illuminate/contracts: ^10.0 || ^11.0 || ^12.0 || ^13.0
- illuminate/database: ^10.0 || ^11.0 || ^12.0 || ^13.0
- illuminate/events: ^10.0 || ^11.0 || ^12.0 || ^13.0
- illuminate/queue: ^10.0 || ^11.0 || ^12.0 || ^13.0
- illuminate/support: ^10.0 || ^11.0 || ^12.0 || ^13.0
- waqas-badar/php-saga: ^1.0.1
Requires (Dev)
- mockery/mockery: ^1.6
- orchestra/testbench: ^8.0 || ^9.0 || ^10.0 || ^11.0
- phpunit/phpunit: ^10.5 || ^11.0 || ^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
An idiomatic Laravel integration for waqas-badar/php-saga β orchestrator-driven Saga Pattern with backward compensation, workflow suspension, crash recovery, database locking, queues, and testing fakes.
β‘ Highlights
- Dependency Injection in Steps: Resolve saga steps directly via Laravel's Service Container (
LaravelStepResolver) with constructor dependency injection. - Expressive Facade: Manage workflows easily via
Saga::run(),Saga::resume(),Saga::dispatch(), andSaga::builder(). - Database Persistence & Row Locking: Stores snapshots in your database (
sagastable) with concurrency protection usingSELECT ... FOR UPDATE. - Background Queue Integration: Asynchronously dispatch or resume sagas on Laravel Queues (
ExecuteSagaJob,ResumeSagaJob). - Suspension & Webhook Resumption: Pause workflows awaiting external triggers (payment 3D-Secure, webhooks, human approval) and resume seamlessly without rolling back.
- Laravel Event Bridge: Native mapping of all Saga lifecycle events to Laravel's Event Dispatcher (
Event::listen()). - Artisan CLI: Generators (
make:saga,make:saga-step) and operational tools (saga:show,saga:resume,saga:prune). - First-Class Testing (
Saga::fake()): Assert that sagas were run, resumed, or dispatched without executing external side-effects.
π¦ Installation
Install via Composer:
composer require waqas-badar/laravel-saga
Publish configuration and database migrations:
php artisan vendor:publish --tag="saga-config" php artisan vendor:publish --tag="saga-migrations"
Run the migration:
php artisan migrate
π Quick Start
1. Generate Saga & Steps
php artisan make:saga OrderFulfillmentSaga php artisan make:saga-step ReserveStockStep php artisan make:saga-step ChargeCardStep
2. Define Steps
Step classes implement PhpSaga\Contracts\StepInterface. Constructor dependencies are automatically injected by Laravel:
namespace App\Sagas\Steps; use App\Services\InventoryService; use PhpSaga\Contracts\StepInterface; use PhpSaga\SagaContext; class ReserveStockStep implements StepInterface { public function __construct( private readonly InventoryService $inventory ) {} public function getName(): string { return 'ReserveStock'; } public function execute(SagaContext $context): void { $reservationId = $this->inventory->reserve( $context->get('product_id'), $context->get('quantity') ); $context->set('reservation_id', $reservationId); } public function compensate(SagaContext $context): void { if ($context->has('reservation_id')) { $this->inventory->release($context->get('reservation_id')); } } }
3. Define the Saga Workflow
Extend PhpSaga\AbstractSaga and register steps in order:
namespace App\Sagas; use App\Sagas\Steps\ChargeCardStep; use App\Sagas\Steps\ReserveStockStep; use PhpSaga\AbstractSaga; use PhpSaga\Execution\SagaBuilder; class OrderFulfillmentSaga extends AbstractSaga { public function configure(SagaBuilder $builder): void { $builder ->addStep(ReserveStockStep::class, retries: 2, retryDelayMs: 200) ->addStep(ChargeCardStep::class); } }
4. Execute Workflow
use App\Sagas\OrderFulfillmentSaga; use LaravelSaga\Facades\Saga; $result = Saga::run(OrderFulfillmentSaga::class, [ 'product_id' => 101, 'quantity' => 2, 'amount' => 49.99, ]); if ($result->isSuccessful()) { // Order processed successfully $chargeId = $result->getContext()->get('charge_id'); } elseif ($result->isCompensated()) { // Failed and rolled back in reverse order (LIFO) $error = $result->getExecutionException()->getMessage(); }
βΈοΈ Suspension & Webhook Resumption
Steps can halt execution intentionally awaiting external async callbacks (e.g., 3D-Secure bank verification or webhooks):
public function execute(SagaContext $context): void { $redirectUrl = $this->gateway->createAuthorization($context->get('amount')); $context->set('redirect_url', $redirectUrl); // Halt workflow without triggering rollback: $context->suspend('Awaiting customer 3D-Secure approval'); }
When the customer completes verification and the external webhook hits your controller, resume the saga with the webhook payload:
use App\Sagas\OrderFulfillmentSaga; use Illuminate\Http\Request; use LaravelSaga\Facades\Saga; public function handleWebhook(Request $request, string $sagaId) { $result = Saga::resume( sagaId: $sagaId, saga: OrderFulfillmentSaga::class, additionalContext: [ 'payment_token' => $request->input('token'), 'verified' => true, ] ); return response()->json(['status' => $result->getState()->status->value]); }
Completed steps are automatically skipped, and execution continues from the next pending step!
β‘ Background Queues
Dispatch sagas or resumptions to run asynchronously on your Laravel queues:
use App\Sagas\OrderFulfillmentSaga; use LaravelSaga\Facades\Saga; // Asynchronously execute in background: Saga::dispatch(OrderFulfillmentSaga::class, ['order_id' => 500]) ->onQueue('sagas') ->delay(now()->addSeconds(5)); // Asynchronously resume: Saga::dispatchResume($sagaId, OrderFulfillmentSaga::class, ['approved' => true]);
π‘ Observability & Events
Listen to saga execution events using Laravel's standard event system in EventServiceProvider or AppServiceProvider:
use Illuminate\Support\Facades\Event; use PhpSaga\Events\SagaCompleted; use PhpSaga\Events\SagaCompensated; use PhpSaga\Events\SagaSuspended; use PhpSaga\Events\StepFailed; Event::listen(SagaCompleted::class, function (SagaCompleted $event) { Log::info("Saga {$event->state->sagaId} completed successfully."); }); Event::listen(SagaCompensated::class, function (SagaCompensated $event) { Log::warning("Saga {$event->state->sagaId} failed and was compensated."); }); Event::listen(SagaSuspended::class, function (SagaSuspended $event) { // Notify customer to complete approval });
π οΈ Artisan CLI Commands
| Command | Description |
|---|---|
php artisan make:saga {name} |
Scaffold a new Saga class in app/Sagas. |
php artisan make:saga-step {name} |
Scaffold a new Step class in app/Sagas/Steps. |
php artisan saga:show {id} |
Display formatted status, completed steps, and context data. |
php artisan saga:resume {id} |
Resume execution of an interrupted or suspended saga from the terminal. |
php artisan saga:prune --days=30 |
Delete finished saga records older than the retention threshold. |
π§ͺ Testing with Saga::fake()
Write unit and feature tests easily without triggering real external services or databases:
use App\Sagas\OrderFulfillmentSaga; use LaravelSaga\Facades\Saga; public function test_checkout_endpoint_runs_saga(): void { Saga::fake(); $response = $this->postJson('/api/checkout', [ 'product_id' => 101, 'quantity' => 2, ]); $response->assertOk(); // Assert that the saga was run: Saga::assertRun(OrderFulfillmentSaga::class, function (array $context) { return $context['product_id'] === 101; }); Saga::assertRunCount(1); Saga::assertNotDispatched(OtherSaga::class); }
π‘ Runnable Example Demo
You can run the interactive standalone demonstration script in the examples/ directory to see happy paths, rollbacks, and workflow suspension/resumption in action:
# Run happy path and rollback compensation demo: php examples/demo.php # Suspend a saga awaiting 3D-Secure bank OTP: php examples/demo.php --halt # Resume the suspended saga: php examples/demo.php --resume order-halt-sample-01
π€ Credits & Acknowledgments
- Author & Maintainer: Waqas Badar (@waqas-badar)
- Architecture & Development: Developed in collaboration with Antigravity (Google DeepMind).
π License
This package is open-source software licensed under the MIT license.