PHPackages                             splitstack/laravel-conveyor - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. splitstack/laravel-conveyor

ActiveLibrary

splitstack/laravel-conveyor
===========================

00PHP

Since Aug 1Pushed todayCompare

[ Source](https://github.com/EmilienKopp/laravel-conveyor)[ Packagist](https://packagist.org/packages/splitstack/laravel-conveyor)[ RSS](/packages/splitstack-laravel-conveyor/feed)WikiDiscussions main Synced today

READMEChangelogDependenciesVersions (1)Used By (0)

splitstack/laravel-conveyor
===========================

[](#splitstacklaravel-conveyor)

A lightweight saga pattern for Laravel. Brings explicit compensation contracts to Actions, UseCases, and Sequences.

---

The Problem
-----------

[](#the-problem)

When a sequence sequences DB writes alongside external API calls, no single transaction can provide consistency. DB transactions are local; Stripe charges, S3 puts, and webhook calls are not rollbackable. Standard try/catch leaves the compensation logic scattered and implicit.

This package makes it structural.

---

Core Concepts
-------------

[](#core-concepts)

```
Action        →  atomic unit. One thing. Declares its own undo.
UseCase       →  orchestrates Actions. Owns a DB transaction. Is itself undoable.
Sequence  →  sequences UseCases/Actions via Steps. Owns the error boundary.
Step          →  thin adapter around any existing class. Declares compensation for code that can't.

```

**DB rollback is owned by the transaction. `undo()` is for everything else** (API calls, S3, webhooks). An Action whose only mutations are DB writes declares an empty `undo()` which is the explicit answer to the compensation question.

---

Installation
------------

[](#installation)

```
composer require splitstack/laravel-conveyor
```

The service provider is auto-discovered.

---

Actions
-------

[](#actions)

```
class ChargeStripe extends Action
{
    public function __construct(private readonly StripeClient $stripe) {}

    public function handle(...$args): string
    {
        [$order] = $args;
        return $this->stripe->charge($order->total); // returns chargeId
    }

    public function undo(mixed $result = null): void
    {
        // $result is what handle() returned — no DB lookup needed
        $this->stripe->refund($result);
    }
}

// DB-only action: transaction handles rollback, nothing to undo externally
class CreateOrder extends Action
{
    public function handle(...$args): Order { /* ... */ }
    public function undo(mixed $result = null): void {}
}
```

---

UseCases
--------

[](#usecases)

A UseCase is a `CompensableScope` over its Actions and is itself `Compensable` so a parent pipeline can undo it, cascading compensation down to every Action it ran.

```
class PlaceOrder extends UseCase
{
    public function __construct(
        Transactioner $transactioner,
        private readonly CreateOrder $createOrder,
        private readonly ChargeStripe $chargeStripe,
    ) {
        parent::__construct($transactioner);
    }

    public function handle(...$args): Order
    {
        [$customer, $amount] = $args;

        return $this->executeWithEvents(function () use ($customer, $amount): Order {
            $order    = $this->step($this->createOrder, $customer, $amount);
            $chargeId = $this->step($this->chargeStripe, $order);

            $order->recordEvent('order.placed', ['orderId' => $order->id]);

            return $order;
        });
    }
}
```

- `step()` executes an Action and registers it for potential compensation.
- Domain events recorded inside `executeWithEvents()` are dispatched only after the outermost transaction commits but never on failure.
- `undo()` is inherited: it replays all registered Action undos in reverse.

---

SequencePayload
---------------

[](#sequencepayload)

A typed DTO that travels through the pipeline. Constructor-promoted properties for entry-time data; the inherited `set/get/has` bag for values produced mid-pipeline.

```
class CheckoutPayload extends SequencePayload
{
    public function __construct(
        public readonly string $customer,
        public readonly int $amount,
    ) {}
    // 'order', 'chargeId', 'shipmentRef' added via set() by Steps
}
```

---

Steps
-----

[](#steps)

A Step adapts any existing class, including code that is closed for modification, into the pipeline. It extracts what the callee needs from the payload, calls it, and **declares compensation as its own behavior**.

```
class PlaceOrderStep implements Steppable, Undoable
{
    use IsSteppable; // wires __invoke() → handle()

    public function __construct(private readonly PlaceOrder $placeOrder) {}

    public function handle(CheckoutPayload $payload): void
    {
        $order = $this->placeOrder->handle($payload->customer, $payload->amount);
        $payload->set('order', $order);
    }

    public function undo(mixed $result = null): void
    {
        // delegates to the UseCase's own cascade
        $this->placeOrder->undo();
    }
}

// Wrapping a legacy service that knows nothing about this package:
class AwardLoyaltyStep implements Steppable, Undoable
{
    use IsSteppable;

    public function __construct(private readonly LegacyLoyaltyService $loyalty) {}

    public function handle(CheckoutPayload $payload): void
    {
        $this->loyalty->execute($payload->customer, 10);
    }

    public function undo(mixed $result = null): void
    {
        /** @var CheckoutPayload $result */
        $this->loyalty->revoke($result->customer, 10);
    }
}
```

Steps declare `requires(): array` to skip themselves when payload keys are absent:

```
public function requires(): array
{
    return ['order']; // skipped (and never tracked for undo) if 'order' not in payload
}
```

---

Sequences
---------

[](#sequences)

Extend `Sequence` and call `steps()` / `skippable()` / `run()`. Call `transacts()` to wrap the whole sequence in one DB transaction (inner UseCase transactions become savepoints). Without it, each step commits on its own and only external state is compensated on failure.

```
final class CheckoutSequence extends Sequence
{
    public function __construct(
        Transactioner $transactioner,
        private readonly PlaceOrderStep $placeOrder,
        private readonly AwardLoyaltyStep $awardLoyalty,
        private readonly BookShipmentStep $bookShipment,
    ) {
        parent::__construct($transactioner);
    }

    public function checkout(CheckoutPayload $payload): CheckoutPayload
    {
        return $this
            ->transacts()
            ->steps([$this->placeOrder, $this->awardLoyalty])
            ->skippable($this->bookShipment, fn(CheckoutPayload $p) => $p->get('shippable', true))
            ->run($payload);
    }
}
```

On failure at any step:

1. **DB**: the outer transaction rolls back all writes from all steps, including released savepoints.
2. **External state**: `undo()` cascades in reverse through every completed step.

---

Delegation (async steps)
------------------------

[](#delegation-async-steps)

Some steps don't need to run in-band: a webhook notification, a downstream sync, anything that can happen after the request commits. Mark them with `delegate()` and they are dispatched to the queue instead of invoked inline.

```
return $this
    ->transacts()
    ->steps([$this->placeOrder])
    ->delegate($this->notifyWarehouse)        // runs on the queue
    ->run($payload);
```

This is an unopinionated option, not a policy. What the package does and does not do:

- **Dispatch, not run.** A delegated step is dispatched via `ConveyorStepJob`, which carries the step's class name and the payload. On the worker the step is rebuilt from the container (collaborators re-injected, never serialized). It is *not* tracked on the parent's rewind stack.
- **Self-compensation.** When the job exhausts its retries, its `failed()` hook calls the step's own `rewind()`. Delegated steps compensate themselves; the parent sequence's cascade does not reach them.
- **Retry policy.** If the step declares `getRetryConfig()`, it maps onto the job: `tries` → `$tries`, `backoff` → `$backoff`, `timeout` → `$timeout`. No config means a single attempt.
- **`afterCommit` (default on).** By default dispatch waits for the surrounding transaction to commit, so inline / `transacts()` writes land before the worker reads them. Pass `delegate($step, afterCommit: false)` to dispatch immediately — your call.
- **Ordering.** Multiple `delegate()` calls form a single chain in declaration order. If a terminal step depends on delegated work finishing, delegate it too as the chain tail (or move it into a chain callback).
- **Serialization is yours.** There is no `SerializablePayload` contract and no runtime guard. If the payload doesn't serialize, Laravel throws — that's the signal. Idempotency and payload independence are the developer's responsibility.

Domain events are the alternative when you want post-commit side effects without the queue. The package holds no opinion on which you reach for.

```
->delegate($step)                       // afterCommit, retries from getRetryConfig()
->delegate($step, afterCommit: false)   // dispatch now, don't wait for commit
->delegate($step, when: fn ($p) => $p->get('notify'))  // conditional
```

---

Graceful abort
--------------

[](#graceful-abort)

Throw `SequenceAbortedException` from any step to stop cleanly. Completed work commits, events fire, no compensation runs.

```
public function handle(CheckoutPayload $payload): void
{
    if (!$payload->get('needsSync')) {
        throw new SequenceAbortedException();
    }
}
```

---

Compensation failure
--------------------

[](#compensation-failure)

If `undo()` itself throws, the cascade continues (no step is abandoned) and the failure is reported:

```
$sequence->onCompensationFailed(function (FailedCompensation $f): void {
    // $f->action, $f->result, $f->exception, $f->cause, $f->failedAt
    SlackAlert::send("Compensation failed: {$f->action::class}");
});

// Retry it later — e.g. from a queued job:
$f->retry(); // calls $f->action->undo($f->result)
```

Default behavior when no hook is set: `Log::error`.

---

Per-step retry
--------------

[](#per-step-retry)

Actions and UseCases opt into retry by overriding `getRetryConfig()`. `isUnrecoverableError()` must then be implemented: it is the explicit declaration that some failures should not be retried.

```
class SyncExternalListing extends Action
{
    public function getRetryConfig(): ?RetryConfig
    {
        return RetryConfig::make(tries: 3, backoff: 2, timeout: 30);
    }

    public function isUnrecoverableError(\Throwable $e): bool
    {
        return $e instanceof AuthenticationException;
    }

    public function handle(...$args): mixed { /* ... */ }
    public function undo(mixed $result = null): void { /* ... */ }
}
```

When retries are exhausted, the `onStepFailed` hook fires before compensation begins. The `FailedStep` object carries everything needed to dispatch a Laravel queue job:

```
$scope->onStepFailed(function (FailedStep $f): void {
    // RetryConfig maps directly to job properties:
    // $tries = $f->retryConfig->tries
    // $backoff = $f->retryConfig->backoff
    // $timeout = $f->retryConfig->timeout
    RetryStepJob::dispatch($f->action, $f->retryConfig);
});
```

---

Nested transaction behavior
---------------------------

[](#nested-transaction-behavior)

ContextDB::transaction() behaviorAction inside a UseCasesavepoint (released on UseCase "commit")UseCase inside a Sequencesavepoint (rolled back if pipeline rolls back)UseCase called standaloneoutermost transaction, commits immediately`DB::afterCommit` defers to the outermost transaction in all cases. Events recorded inside a UseCase nested in a pipeline fire only when the pipeline itself commits.

---

Diagrams
--------

[](#diagrams)

### Compensation hierarchy

[](#compensation-hierarchy)

Both `Sequence` and `TransactionalBoundary` use the `ManagesUndoStack` trait, so each has its own undo stack. On failure, each layer calls `compensate()` on what it directly tracked: the pipeline compensates its Steps, each UseCase compensates its own Actions (and nested UseCases cascade further).

 ```
graph TD
    WP["Sequence\n(ManagesUndoStack)"]

    subgraph PipelineScope ["↩ Pipeline compensates these on failure (in reverse)"]
        S1["Step 1  (Undoable)"]
        S2["Step 2  (Undoable)"]
    end

    UC1["UseCase 1\nTransactionalBoundary\n(ManagesUndoStack)"]
    UC2["UseCase 2\nTransactionalBoundary\n(ManagesUndoStack)"]

    subgraph UC1Scope ["↩ UseCase 1 compensates these on failure (in reverse)"]
        A1["Action A  (Undoable)"]
        A2["Action B  (Undoable)"]
    end

    subgraph UC2Scope ["↩ UseCase 2 compensates these on failure (in reverse)"]
        A3["Action C  (Undoable)"]
        A4["Nested UseCase  (Undoable)\ncascades to its own inner Actions"]
    end

    WP --> S1
    WP --> S2
    S1 -->|wraps| UC1
    S2 -->|wraps| UC2
    UC1 --> A1
    UC1 --> A2
    UC2 --> A3
    UC2 --> A4
```

      Loading When Action C (inside Step 2 / UseCase 2) fails, the unwinding is:

1. `UseCase 2` catches → `DB::rollBack()` to savepoint → `compensate()` → `Action C.undo()` (external side-effects only)
2. Exception bubbles to `Sequence` → `DB::rollBack()` real transaction (wipes all DB writes) → `compensate()` → `Step 1.undo()` (Step 2 never completed, so it was never tracked)

---

### Execution sequence

[](#execution-sequence)

`Sequence` and `TransactionalBoundary` both call `DB::beginTransaction()` via `Transactioner`. Laravel handles nesting with savepoints: the first call opens a real transaction; nested calls create a savepoint. The pipeline holds the outer transaction; each UseCase inside runs on a savepoint. On failure, the UseCase rolls back to its savepoint and calls `compensate()` on its actions, then the exception bubbles up and the pipeline rolls back the real transaction and calls `compensate()` on completed Steps.

 ```
sequenceDiagram
    participant C as Caller
    participant WP as Sequence
    participant S as Step adapter
    participant UC as UseCase (TransactionalBoundary)
    participant A1 as Action or UseCase 1
    participant A2 as Action or UseCase 2
    participant Ext as External API

    C->>WP: run(payload)
    WP->>WP: DB::beginTransaction() — real transaction (level 1)

    loop for each Step
        WP->>S: invoke(payload)
        S->>S: extract typed args from payload
        S->>UC: handle(args)

        UC->>UC: DB::beginTransaction() — savepoint (level 2)

        UC->>A1: step(action1, args)
        note over A1: Action or nested UseCase —either way it's Undoable
        A1->>Ext: external call (e.g. charge)
        Ext-->>A1: result
        A1-->>UC: result  [tracked for undo]

        UC->>A2: step(action2, args)
        note over A2: if this is a nested UseCase,its own undo() cascades to its inner Actions
        A2-->>UC: result  [tracked for undo]

        alt all actions succeed
            UC->>UC: DB::commit() — releases savepoint (level 2→1)
            note over UC: DB writes stay in the outer transactionuntil the pipeline commits
            UC-->>S: return result
            S->>S: write result back to payload
            S-->>WP: done
        else any action fails
            UC->>UC: DB::rollBack() — rollback to savepoint (level 2→1)
            UC->>A2: undo()  [reverse order — external side-effects only]
            UC->>A1: undo()
            A1->>Ext: compensate (e.g. refund)
            UC-->>WP: raise exception
            WP->>WP: DB::rollBack() — rollback real transaction (level 1→0)
            note over WP: wipes all remaining DB writesfrom all Steps in this run
            WP->>S: undo() previously completed Steps
        end
    end

    WP->>WP: DB::commit() — commits real transaction (level 1→0)
    WP-->>C: completed / exception
```

      Loading ---

Scope of this package
---------------------

[](#scope-of-this-package)

This package provides **best-effort, in-process compensation with explicit contracts**. It is not durable execution, if the process crashes between an external mutation and its `undo()`, no compensation runs. For guaranteed compensation across process restarts, consider Temporal.io or a durable sequence engine.

The `onStepFailed` / `onCompensationFailed` hooks are the extension points for plugging in queue-backed retry.

###  Health Score

20

↑

LowBetter than 12% of packages

Maintenance65

Regular maintenance activity

Popularity0

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity11

Early-stage or recently created project

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

### Community

Maintainers

![](https://www.gravatar.com/avatar/b9e77e55bb8341a1fa99f8348597070b4de230b5fc457e866e4f196dd56843f1?d=identicon)[EmilienKopp](/maintainers/EmilienKopp)

---

Top Contributors

[![EmilienKopp](https://avatars.githubusercontent.com/u/91975560?v=4)](https://github.com/EmilienKopp "EmilienKopp (9 commits)")

### Embed Badge

![Health badge](/badges/splitstack-laravel-conveyor/health.svg)

```
[![Health](https://phpackages.com/badges/splitstack-laravel-conveyor/health.svg)](https://phpackages.com/packages/splitstack-laravel-conveyor)
```

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
