micro-module/saga
A Laravel package for orchestrating sagas: chain distributed transaction steps and automatically run compensating actions when one fails, keeping multi-service workflows consistent without long-lived locks.
Install and publish the scaffolding
composer require micro-module/saga
php artisan vendor:publish --provider="MicroModule\Saga\SagaServiceProvider"
php artisan migrate
Publishing should give you a config/saga.php and one or more tables for persisted saga state (sagas, saga_steps or similarly named — check the migration stub before running it, since naming is not standardised across micro-module packages).
Where to look first
config/saga.php — connection/queue for the orchestrator, retry counts, table names, and the compensation strategy switch. This is the file that determines whether the package runs inline or on a queue.src/Contracts/ — the step/compensatable interfaces. With zero stars and zero dependents there is no tutorial ecosystem; the interfaces are the documentation.src/Concerns/ or src/Models/ — the persisted saga model, which is your debugging window in production.First use case
Do not start with your hardest workflow. Pick a two-step cross-module operation with an obvious inverse, e.g. reserve stock in the Inventory module → charge card in the Billing module, where the compensations are release stock and refund. Build that end to end, including a forced failure on step two, and watch the compensation run. Only then add steps three and four.
If your operation lives entirely inside one database, stop — use DB::transaction(). A saga buys you eventual consistency across boundaries you cannot wrap in one ACID transaction; paying that complexity for a single-DB write is a net loss.
Orchestration, not choreography
The package is orientated around a central coordinator that knows the step order and the compensations. Keep it that way: put the saga definition in one class per business process (e.g. CheckoutSaga), and let each module expose plain, idempotent service methods. Resist the temptation to have modules emit events that trigger each other — you lose the ability to answer "where is order 8812 stuck?" with a single query.
Define steps as do/undo pairs, in forward order
class CheckoutSaga extends Saga
{
public function steps(): array
{
return [
ReserveStock::class, // do: hold inventory undo: release hold
ChargeCard::class, // do: capture payment undo: refund
CreateShipment::class, // do: book courier undo: cancel booking
];
}
}
Compensations run in reverse. Keep each step class small and delegate to the owning module's service — the step should contain orchestration logic only, never business rules, or you will duplicate them between the saga and the module.
Persist the payload, not the model
Serialise step inputs to scalars/arrays (IDs, amounts, currency, idempotency keys). Serialising Eloquent models into saga state is the single most common cause of "the retry ran against a stale, deleted record". Re-fetch inside the step.
Idempotency keys per step
Every step that touches an external system must be safe to run twice, because retries and manual replays are a fact of life. Generate a deterministic key from the saga ID plus step name and pass it downstream:
$key = "saga:{$saga->id}:charge-card";
$this->payments->charge($order, $amount, idempotencyKey: $key);
Timeouts as first-class steps
Model "wait for the warehouse to acknowledge" as a step with a deadline, not as a blocking call. Sagas that sit in running forever usually mean a step swallowed an exception or is polling something that will never change. Give every wait a ->timeout() and a compensation path.
Integrate at the module boundary, not inside it
Micro-module setups (nwidart-style) mean each module owns its migrations and models. The saga should call a module's service or action class, never reach into another module's repository. This keeps the saga package swappable and, more practically, keeps your module test suite green without booting the orchestrator.
Testing
Test steps in isolation with the module's fakes, then test the saga with a deliberately failing step:
$this->mock(ChargeCard::class)->shouldReceive('handle')
->andThrow(new PaymentDeclinedException());
$saga = CheckoutSaga::start($order);
$this->assertSame('compensated', $saga->fresh()->status);
$this->assertTrue(StockWasReleased::fired());
Assert on the compensation side effects, not just the status column — a saga that marks itself compensated without actually calling the undo is worse than one that stays stuck.
A saga is not a transaction. Compensations are semantic, not physical: a refund is not the inverse of a charge (fees, FX, timing), and "release stock" can fail if the warehouse already picked it. Design every undo assuming it can itself fail and may need manual reconciliation. Surface those failures loudly rather than silently marking the saga complete.
Order of operations in handle() matters. If your step writes to the DB and then calls an external API, a crash between the two leaves you with local state that has no remote counterpart. Prefer: commit the local record in a short transaction, then call out, then mark the step done — and make the reconciliation job aware of the gap.
Queue + DB transaction interaction. If the orchestrator dispatches steps onto a queue, do not dispatch inside a DB::transaction() that might roll back — you will fire a job for a saga row that never existed. Use DB::afterCommit() or dispatch()->afterCommit().
Migrations are unversioned and unowned. With no dependents and a 0.x-style package, expect table/column renames between releases. Pin the version in composer.json and diff the migration stub on upgrade rather than blindly running migrate.
Configuration quirks worth checking immediately: which DB connection the saga tables use (it should be one that is not inside the modules' own connections), the queue name and worker timeout (a saga step that outlives the worker's --timeout will be retried and double-execute), and whether retries are counted per step or per saga.
Debugging a stuck saga. Query the step table directly for status = 'running' with an updated_at older than your longest step, and cross-reference against failed_jobs. Add an artisan command of your own for this — the package almost certainly does not ship one.
Extension points to look for. The most useful hooks in packages of this shape are a beforeStep/afterStep event pair and a pluggable compensation strategy. If they exist, use them to bolt on structured logging (saga ID in every log line) and metrics; if they do not, wrap the base saga class and inject them rather than forking.
Replay tooling is your problem. Because there are no dependents, nobody has published a replay/inspect UI. Budget a day for a small admin screen or command that lists sagas by status and lets you re-drive a single step with an operator-supplied payload. You will need it the first week you ship.
Bypass the package for read paths. Sagas coordinate writes; never make a page render wait on saga state. Emit a domain event on completion and let the read model consume it.
How can I help you explore Laravel packages today?