sients/compensator

Lightweight, synchronous Saga pattern orchestrator for Laravel. Chain steps with execute()/compensate(), auto-rollback completed steps in reverse order on failure — no queues, no database, no migrations.

Maintainers

Package info

github.com/sients/compensator

pkg:composer/sients/compensator

Transparency log

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-08-13 15:33 UTC

This package is auto-updated.

Last update: 2026-08-13 15:48:29 UTC


README

Latest version Tests Downloads License

Lightweight, synchronous Saga pattern orchestrator for Laravel. Chain steps with execute()/compensate(), and on failure Compensator automatically rolls back every completed step in reverse order — no queues, no database, no migrations.

If you don't need durable/replayable workflows (Temporal-style, surviving worker restarts), and just need to safely unwind a sequence of side effects that happened within a single request — payment charged, partner API called, external record created — this is for that.

Requirements

PHP 8.2+ and Laravel 12 or 13.

Laravel 10 and 11 are past their security-fix end of life, so they are not supported — recent Composer versions refuse to install them at all.

Installation

composer require sients/compensator

Laravel auto-discovers the service provider. No config file, no vendor:publish, no migrations to run.

Usage

use Compensator\Compensator;
use Compensator\CompensatorContext;

class ChargePayment implements \Compensator\Contracts\CompensatorStep
{
    public function execute(CompensatorContext $context): mixed
    {
        $payment = PaymentGateway::charge($context->get('amount'));
        $context->set('payment_id', $payment->id);

        return $payment;
    }

    public function compensate(CompensatorContext $context): void
    {
        PaymentGateway::refund($context->get('payment_id'));
    }
}

class CreatePartnerPolicy implements \Compensator\Contracts\CompensatorStep
{
    public function execute(CompensatorContext $context): mixed
    {
        $policy = PartnerApi::createPolicy($context->all());
        $context->set('policy_id', $policy->id);

        return $policy;
    }

    public function compensate(CompensatorContext $context): void
    {
        PartnerApi::cancelPolicy($context->get('policy_id'));
    }
}

$result = (new Compensator())
    ->addStep(new ChargePayment())
    ->addStep(new CreatePartnerPolicy())
    ->step(
        execute: fn ($ctx) => Pdf::generate($ctx->get('policy_id')),
        // compensate is optional — omit it for a step with nothing to undo
        name: 'generate_pdf',
    )
    ->run(new CompensatorContext(['amount' => 4999]));

if ($result->needsManualCleanup()) {
    // The chain failed AND rollback failed — a side effect is stranded.
    foreach ($result->compensationFailures as $failure) {
        logger()->critical('stranded side effect', [
            'step'     => $failure->stepName,   // 'ChargePayment'
            'attempts' => $failure->attempts,
            'error'    => $failure->exception->getMessage(),
            'context'  => $result->context->snapshot(redact: ['card_number']),
        ]);
    }
}

Name your steps

$stepName is what tells you which side effect is stranded, so it is the one thing worth getting right. A step class is named after itself, which is usually enough:

->addStep(new ChargePayment())              // 'ChargePayment'
->addStep(new ChargePayment(), 'retry_charge') // or override it

Inline steps all share one class, so name them:

->step(execute: ..., compensate: ..., name: 'charge_payment')

Without a name an inline step falls back to its position — closure step #0 — which keeps the report unambiguous but tells you nothing about what it did.

CompensatorResult carries:

Member Meaning
$successful Every step completed.
$context The context the chain ran with — populated even on failure.
$stepResults What each completed step's execute() returned, in order.
$failureCause The exception that broke the chain, or null on success.
$compensationFailures CompensationFailure objects ($step, $stepName, $stepIndex, $exception, $attempts).
needsManualCleanup() The one to alert on — failed chain and failed rollback.
fullyCompensated() Failed chain, clean rollback. Note this is false for a successful run too, since nothing was rolled back.

One chain, one run

A Compensator is a mutable, single-use builder: every method mutates and returns the same instance (like Laravel's own Http::withHeaders()), and run() may be called only once — a second call throws LogicException rather than silently repeating side effects that may already have been compensated. Build a fresh chain per run, or resolve one from the container:

$compensator = app(Compensator::class); // a new, empty chain every time

Failure strategy

By default, if one compensate() call throws, Compensator keeps rolling back the rest of the chain instead of stopping — a partial rollback is usually worse than an attempted full one. Every failure is reported on $result->compensationFailures, never swallowed silently.

use Compensator\Enums\CompensationFailureStrategy;

(new Compensator())
    ->withFailureStrategy(CompensationFailureStrategy::StopOnFirstFailure)
    // ...

Retrying a rollback

A refund API that blips for a second is otherwise the difference between a recoverable failure and money stranded for good. Retries are off by default:

(new Compensator())
    ->retryCompensation(times: 2, sleepMs: 200)
    // ...

That is one attempt plus two retries per step being rolled back, with a fixed pause between them. $failure->attempts and the attempts property on CompensationSucceeded/CompensationFailed tell you how many it took.

Two things to keep in mind. The retries are synchronous, inside a request that is already failing — times × sleepMs is added to the response for every step that needs retrying, so keep both numbers small. And retrying means compensate() runs more than once, so it has to be idempotent (see below).

compensate() must be idempotent

Compensator can call the same compensate() more than once: retryCompensation() does it on failure, the shutdown guard resumes a rollback the dying process had started, and whoever handles a CompensationFailure runs the same undo by hand afterwards.

So check before acting rather than assuming the effect is still in place:

public function compensate(CompensatorContext $context): void
{
    $payment = PaymentGateway::find($context->get('payment_id'));

    if ($payment?->isRefundable()) {
        $payment->refund();
    }
}

Logging the context

$context->all() returns the raw values, which may include Eloquent models or gateway response objects — dropping those into a log line is how a card number ends up in your log aggregator. snapshot() gives you a version that is safe to log: scalars survive, everything else becomes a short type description, and you can mask keys outright.

$context->snapshot(redact: ['card_number']);
// ['amount' => 4999, 'payment_id' => 'pay_123', 'card_number' => '[redacted]',
//  'gateway_response' => 'object(App\Payments\Response)']

The simplest defence is still to keep only scalar identifiers in the context in the first place.

Rolling back the step that failed

By default a step whose own execute() throws is not compensated — only the steps before it are. That is the right default, because most compensate() implementations read a value that execute() writes to the context on its last line.

If a step can leave a side effect behind before throwing (the charge went through, then persisting the id failed), opt in:

(new Compensator())
    ->compensateFailedStep()
    // ...

Then that step's compensate() must tolerate a context execute() never finished filling in:

public function compensate(CompensatorContext $context): void
{
    if (! $context->has('payment_id')) {
        return;
    }

    PaymentGateway::refund($context->get('payment_id'));
}

Working with database transactions

Compensator is not a replacement for DB::transaction(), and it never opens one for you. They cover different boundaries: a transaction gives you atomicity inside one database, Compensator unwinds effects that live outside it — a charged card, a partner API record, a file on S3.

If all of your work is in one database, you do not need this package. Use a transaction.

Do not wrap a whole chain in a transaction:

// Don't do this.
DB::transaction(function () {
    (new Compensator())
        ->addStep(new ChargePayment())      // external HTTP call
        ->addStep(new CreatePartnerPolicy()) // external HTTP call
        ->run();
});

Two things go wrong. The connection is held open across every external HTTP call, so network latency turns into held locks and exhausted pool connections. And on failure, the rollback discards anything your compensate() methods wrote to that same database — the refund audit row you created while unwinding disappears along with everything else, leaving you with a real refund and no record of it.

Instead, keep each step's database work atomic inside the step, and let Compensator orchestrate across the boundaries:

final class CreateOrder implements CompensatorStep
{
    public function execute(CompensatorContext $context): mixed
    {
        // Atomic within this step, committed before the next one starts.
        $order = DB::transaction(fn () => Order::create([...]));

        $context->set('order_id', $order->id);

        return $order;
    }

    public function compensate(CompensatorContext $context): void
    {
        DB::transaction(fn () => Order::whereKey($context->get('order_id'))->delete());
    }
}

The trade-off is explicit: between two steps there is a window where the first step's work is committed and the second has not run. That is the Saga bargain — eventual consistency in exchange for not holding a transaction across a network call.

Observability

No persistence layer means no built-in dashboard — but every transition dispatches a Laravel event you can subscribe to and route wherever you already send logs/metrics. Inside Laravel this works with a plain new Compensator() — it picks up the application's dispatcher automatically:

use Illuminate\Support\Facades\Event;
use Compensator\Events\CompensationFailed;

Event::listen(function (CompensationFailed $e) {
    logger()->critical('compensation failed', ['step' => $e->stepName]);
});

Available events:

Event Properties
StepSucceeded $step, $stepName, $stepIndex, $result (whatever execute() returned)
StepFailed $step, $stepName, $stepIndex, $exception
CompensationSucceeded $step, $stepName, $stepIndex, $attempts
CompensationFailed $step, $stepName, $stepIndex, $exception, $attempts

$stepIndex is the step's zero-based position in the chain, matching $result->stepResults.

Listeners are strictly observational: an exception thrown inside one is swallowed and never changes the outcome of the chain — a listener that throws must not roll back a step that actually succeeded or, worse, prevent a rollback from running at all.

Swallowed is not the same as lost, though. If your CompensationFailed listener is the only record of a stranded side effect and it throws, the incident would disappear, so the dropped exception is passed to the application's ExceptionHandler (or error_log() when there is no container). Call withoutEvents() to silence a single chain.

Surviving a dead process

This is the trade-off behind "no database". If PHP dies between steps — an out-of-memory fatal, an exceeded max_execution_time, a worker terminated mid-deploy — none of that is catchable, so the rollback never runs and nothing is written anywhere. You are left with a charged card and no record of it.

protectAgainstFatals() installs a shutdown handler that attempts the rollback anyway:

(new Compensator())
    ->protectAgainstFatals()
    ->addStep(new ChargePayment())
    // ...

PHP still runs shutdown handlers after a fatal, so this recovers the two most common ways a request dies. It reserves a little memory up front and frees it on entry so a rollback is still possible after an out-of-memory kill. The outcome goes to your event listeners and to ExceptionHandler/error_log, since there is no longer anyone to hand a CompensatorResult to.

It is best effort, not a guarantee. Nothing survives SIGKILL, a segfault, or the machine losing power, and a rollback running after a timeout may itself be cut short. If a stranded side effect is genuinely unacceptable — real money, anything legally binding — do not rely on this alone: write an audit row from the StepSucceeded listener, or use a durable engine instead.

If the process dies part-way through a rollback, the guard resumes it rather than restarting it: a step whose rollback already finished is not undone a second time. A step that was in the middle of being compensated when the process died is retried, which is one of the reasons compensate() has to be idempotent.

Safe under Octane, RoadRunner and Swoole: the guard installs a single shutdown handler per process and retains nothing once a run finishes, so it does not accumulate across the requests a worker serves.

One more thing worth planning for: the rollback spends the user's request budget. A chain that fails on step five then makes four more outbound calls to undo, in a request that is already slow — and retryCompensation() multiplies that. It is exactly how you reach the timeout above.

What this deliberately does not do

  • No queue dispatch, no worker infrastructure
  • No persisted run state, no replay
  • No approval gates, no signals, no DAG execution
  • No guaranteed recovery if the process dies — see Surviving a dead process

If you need any of those, look at Durable Workflow (durable-workflow/workflow, formerly Laravel Workflow) or Saga Lara Flow (discovery-ukraine/saga-lara-flow) instead — they solve a different problem (durable, restart-surviving execution) at the cost of a queue + database dependency.

Testing

composer test

Style and static analysis run alongside the suite — this is what CI runs:

composer check

Contributing

See CONTRIBUTING.md. Security issues: see SECURITY.md.

Changelog

See CHANGELOG.md.

License

MIT.