stancl/jobpipeline
JobPipeline converts any series of Laravel jobs into event listeners. Chain jobs, send event data to them, and queue the pipeline or push it to a specific queue with minimal configuration.
Install
composer require stancl/jobpipeline:^1.9
Pin the stable 1.x line. The 2.0.0-rc* releases exist (currently rc7, Laravel 13 support, reworked queue-job defaults), but both 1.9 and 2.0-rc support Laravel 13 — only reach for ^2.0@rc if you specifically need the new default-configuration hooks.
Minimal end-to-end example
// app/Jobs/ValidateOrder.php, ChargeCard.php, ShipOrder.php — plain Laravel jobs
// app/Providers/EventServiceProvider.php
use Illuminate\Support\Facades\Event;
use Stancl\JobPipeline\JobPipeline;
use App\Events\OrderPlaced;
use App\Jobs\{ValidateOrder, ChargeCard, ShipOrder};
public function boot(): void
{
Event::listen(OrderPlaced::class, JobPipeline::make([
ValidateOrder::class,
ChargeCard::class,
ShipOrder::class,
])->send(fn (OrderPlaced $event) => $event->order)
->shouldBeQueued(true, 'orders')
->toListener());
}
That's the whole API surface: make(), send(), shouldBeQueued(), toListener(). Three jobs that used to need three listener classes and three dispatches are now one linear listener on one queue.
Where to look first
src/JobPipeline.php — the package is essentially a single small class. Read it fully (10 minutes) before you build an abstraction on top of it; there is no hidden magic to discover later.tests/ — the tests are the real documentation for edge cases like payload handling and the false short-circuit.CreateDatabase → MigrateDatabase → SeedDatabase) — it is the canonical "multi-step, stop-if-already-exists" shape you will be copying.First use case to convert
Find a listener with three or more sequential side effects and no branching — tenant provisioning, order fulfillment, export generation, post-signup onboarding. Extract each side effect into a job class (if it isn't one), then wrap them. If your listener ever needs if/else between steps, stop: that belongs inside a job, not between jobs.
Register from a dedicated provider, not $listen
toListener() returns a Closure, and EventServiceProvider::$listen expects class-strings. So the $listen array is not an option — register in boot() (or a dedicated PipelineServiceProvider) with Event::listen(). Keep all pipeline registrations in one place so the workflow graph is greppable:
// app/Providers/PipelineServiceProvider.php
public function boot(): void
{
foreach ($this->pipelines() as $event => $listener) {
Event::listen($event, $listener);
}
}
protected function pipelines(): array
{
return [
TenantCreated::class => JobPipeline::make([CreateDatabase::class, MigrateDatabase::class, SeedDatabase::class])
->send(fn (TenantCreated $e) => $e->tenant)
->shouldBeQueued(queue: 'tenancy')
->toListener(),
OrderPlaced::class => JobPipeline::make([ValidateOrder::class, ChargeCard::class, ShipOrder::class])
->send(fn (OrderPlaced $e) => $e->order)
->shouldBeQueued(queue: 'orders')
->toListener(),
];
}
Map the event to the payload once, in send()
The closure's return value is what every job in the pipeline receives. Return a domain object, not the event — jobs should never know an event triggered them. Type-hint the closure parameter so static analysis and IDE completion work:
->send(function (OrderPlaced $event) {
return $event->order; // Order model — serializable
})
Because the payload is computed once at dispatch time, the pipeline is a point-in-time snapshot. If a later job mutates the model, earlier-job assumptions must hold; don't re-query differently per job.
Use closures for the one-liners
Since v1.5.0 you can mix closures into the job array. This is the cheapest way to replace a trivial listener method without creating a job class:
JobPipeline::make([
fn (Order $order) => $order->update(['status' => 'processing']),
SyncInventory::class,
ChargeCard::class,
])
Rule of thumb: closures for sync pipelines only, or for steps that are genuinely trivial. Queued closure steps become CallQueuedClosure jobs — hard to name in Horizon, harder to retry deliberately.
Gate with false, and split pipelines to gate at the right granularity
Any job returning false from handle() cancels everything after it. Because the cancel is pipeline-wide, split logically separate phases:
JobPipeline::make([CreateDatabase::class])->send(...), // can bail if DB exists
JobPipeline::make([MigrateDatabase::class, SeedDatabase::class])->send(...),
This gives you per-phase bail-out instead of one coarse cancel that skips seeding because the database already existed.
Queue per stage of the app, not globally
shouldBeQueued(queue: 'reports') is sugar for shouldBeQueued(true, 'reports') — the first argument defaults to true since v1.1.0. Use it to route long pipelines to a dedicated worker pool instead of starving your default queue. Keep the global static toggle off; opt in per pipeline. It's far easier to reason about than a process-wide default flipped in a provider.
Testing
Event::fake(); // assert the event fired, pipeline did not run
Bus::fake(); // assert dispatch when the pipeline is queued
Queue::fake(); // assert the pipeline landed on 'orders'
For queued pipelines, Queue::assertPushedOn('orders', ...) on whatever job class the pipeline dispatches; for sync pipelines, call the listener directly with the event and assert DB state. Test the jobs individually as normal Laravel jobs — the pipeline adds no per-job behavior worth a dedicated test beyond ordering and cancellation.
The payload must be serializable when the pipeline is queued. send() runs at dispatch time, but its return value gets pushed through the queue. Eloquent models are fine (serialized by ID); anonymous DTOs, closures, or a collection of models with loaded relations can break or bloat the payload. Keep it to a model or a small value object with SerializesModels semantics.
Sync pipelines run inside the request/command. A synchronous pipeline of three real jobs is a three-job timeout risk with no retry, no visibility, and no Horizon entry. Default to shouldBeQueued(true) for anything touching I/O, and reserve sync pipelines for sub-100ms local work.
The false-cancellation contract depends on the job's return value being observed. Returning false from handle() works cleanly in a sync pipeline. In a queued pipeline the steps are separate queue jobs, so this is exactly the kind of thing to verify with a test before you build a bail-out on it — do not assume false propagates through a queued chain the way it does synchronously.
JobPipeline::$shouldBeQueuedByDefault = true; is global mutable state. Set it once in a provider's boot() and never in tests, or you'll get order-dependent suites. If you do flip it, be aware it changes every pipeline in the app at once, including ones you forgot existed — including during queue:work boot.
Exceptions are thrown, not swallowed. v1.6.0 added error catching, v1.6.1 reverted it to rethrow, and v1.7.1 fixed closure error handling. So a failing job stops the chain and bubbles up. For queued pipelines that means normal Laravel retry semantics apply and the chain restarts from the beginning on retry — so jobs must be idempotent, or a retry after ChargeCard succeeded will charge twice. Design every step as safe-to-rerun, or wrap the non-idempotent bit in a guard (unique constraint, already_charged flag).
No branching, no fan-out, no DAG. The jobs form one ordered array. Conditional logic goes inside a job, and parallel work goes inside a job via Bus::batch(). If you find yourself wanting if step 2 succeeded then 3a else 3b between jobs, the package is the wrong tool.
No built-in observability. There are no pipeline-level events, tags, or logs. Add them yourself: name the jobs well (Horizon displays job classes), tag queued pipelines per queue, and remember that a pipeline's progress is only visible as a sequence of job entries — there is no "pipeline 42 completed" signal.
Per-job queue configuration still applies. A job's own $queue, $tries, $timeout, and $backoff properties are yours to set; the pipeline's queue argument is the dispatch-level default. Give each job in a queued pipeline explicit $tries/$timeout — inheriting worker defaults across a multi-step workflow is how you get a half-migrated tenant.
Check the worker is running on the right queue. shouldBeQueued(queue: 'orders') silently does nothing beyond enqueueing; if your supervisor/Horizon config only processes default, you'll debug a "job never ran" issue that is a config problem. Verify with php artisan queue:work --queue=orders,default.
Extension points are minimal — wrap, don't subclass. With a single class and two static properties, the cleanest extension is a small factory in your app (TenantPipeline::provision()) returning a configured JobPipeline. Subclassing buys you nothing unless you need to override toListener()'s run/cancel logic, and if you're doing that, reevaluate whether you need a workflow engine instead.
How can I help you explore Laravel packages today?