michael-rubel/laravel-enhanced-pipeline
A lightweight Laravel package that extends the native Pipeline with a more convenient API for chaining invokable pipes and composing multi-step workflows. Keeps pipeline logic readable, testable, and easy to maintain.
Install
composer require michael-rubel/laravel-enhanced-pipeline
The package is a thin extension of Illuminate\Pipeline\Pipeline, so there is nothing to wire up beyond the optional service provider Laravel auto-discovers (it exists mainly to register the config/events). If a config file is documented for your version, publish it with the tag listed in the README — otherwise you can skip it entirely.
Version note for v5.0.0: this is a compatibility release (PHP 8.4 tested, Laravel 12 supported) rather than a feature release — no new public API is documented in the changelog. Confirm the illuminate/* and php constraints in composer.json for the tag you install, because major-version bumps of this package have historically been driven by dropping older framework support. If you are on Laravel ≤ 11 or PHP < 8.4, stay on the 4.x line.
Where to look first
README.md — the day-to-day API is small and fits on one screen.src/Pipeline.php — this is the whole package. Read it before you rely on any edge-case behaviour; it is a few hundred lines and removes all guesswork about method names (withTransaction, pipeIf/pipeUnless, thenReturn, finally, and the conditional event toggles — confirm the exact spellings for your pinned version here).src/Events/ — the lifecycle events that fire around a pipeline run.First use case
Take a service class that already chains steps manually, and express it as a pipeline that gets transactional guarantees for free:
use MichaelRubel\EnhancedPipeline\Pipeline;
$order = Pipeline::make()
->withTransaction() // wraps the whole run in DB::transaction()
->send($order)
->through([
ValidateStock::class,
ReserveInventory::class,
ChargePayment::class,
CreateShipment::class,
])
->then(fn (Order $order) => $order->refresh());
Each pipe is just an invokable class (__invoke($payload, Closure $next)) that mutates or replaces the payload and calls $next($payload). If any step throws, the transaction rolls back and the exception propagates to the caller — exactly what you'd otherwise hand-roll with try/catch + DB::rollBack().
Compose the pipe list dynamically, then run it once. Build the array from config, feature flags, or the request shape, and hand it to ->through(). This is where pipelines beat hardcoded service classes:
$pipes = [SanitizePayload::class, ValidatePayload::class];
if ($request->hasFile('attachment')) {
$pipes[] = StoreAttachment::class;
}
if ($user->isEnterprise()) {
$pipes[] = ApplyEnterprisePricing::class;
}
return Pipeline::make()->send($request)->through($pipes)->thenReturn();
Use the conditional helpers for one-off steps so the list stays declarative rather than littered with if blocks (pipeIf / pipeUnless — verify the names in src/Pipeline.php):
Pipeline::make()
->send($payload)
->through([A::class, B::class])
->pipeIf($user->isVip(), DiscountPipe::class)
->pipeUnless(app()->isProduction(), DebugDumpPipe::class)
->thenReturn();
Put withTransaction() on the pipeline, not inside the pipes. The value of the package is that the boundary is declared in one place. Keep pipes transaction-agnostic; they should behave identically whether or not a transaction is active.
Use finally() for teardown. Cache locks, temp files, and metric timers are a natural fit — it runs whether the pipeline succeeds or throws, so you don't need a try/finally around every call site.
Listen to lifecycle events for observability instead of logging inside pipes. Register a listener (queued, if the pipeline is hot) that records start, finish, and failure:
Event::listen(PipelineFinished::class, function ($event) {
Metrics::timing('pipeline.finished', $event->duration ?? 0);
});
Because the events are dispatched centrally, every pipeline in the app gets instrumented with one listener. In tests, Event::fake() lets you assert a pipeline ran without executing side effects.
Wrap pipelines inside jobs/commands, not the other way round. A job that calls a transactional pipeline is a clean unit: retries re-run the whole pipeline, and the transaction boundary is per-attempt. Do not call a transactional pipeline from inside another explicit DB::transaction() unless you understand the savepoint semantics.
Testing pattern: assert both the happy path and the rollback path. The v5 release notes (PHP 8.4 matrix, Laravel 12) are a good prompt to add a CI job on your target PHP/Laravel combination, since that is exactly the axis along which this package moves.
Event::fake();
try {
Pipeline::make()->withTransaction()->send($order)->through([ExplodingPipe::class])->thenReturn();
} catch (Throwable) {
// expected
}
$this->assertDatabaseMissing('inventory_reservations', ['order_id' => $order->id]);
Event::assertDispatched(PipelineStarted::class);
External side effects inside a transaction. The single biggest footgun. Sending mail, dispatching a job to a queue, or calling a payment API inside withTransaction() means the side effect happens even if a later pipe rolls back the DB. Either move those pipes to the end and after the transaction, or use DB::afterCommit() / ShouldHandleEventsAfterCommit for anything that leaves the process.
Nested transactions and savepoints. withTransaction() ultimately calls DB::transaction(), which nests via savepoints. If the caller already opened a transaction, an exception inside the pipeline only rolls back to the savepoint — the outer transaction keeps the work. Decide one place in your architecture owns the transaction boundary and stick to it.
Long pipelines hold locks. Five pipes doing HTTP calls inside a transaction will pin row locks for seconds. Keep pipes fast; anything slow belongs after the transactional section.
Pipes must return the payload. Laravel's pipeline never validates this, and the enhanced version doesn't either. A pipe that returns null (a forgotten return) silently produces a null payload downstream, and you'll only notice at the destination closure. Make __invoke return types explicit (: mixed or a concrete DTO) and add one integration test per pipeline.
finally() must not throw. If a cleanup callback throws during exception handling, you lose the original exception. Wrap risky teardown in its own try/catch and log rather than rethrow.
Conditional pipe ordering is append-only. pipeIf/pipeUnless add to the end of the array. If a conditional step needs to run before an existing stage, build the array conditionally instead of using the helpers.
Event overhead. If you run pipelines in tight loops, disable events for that call (the package exposes a toggle for this — check src/Pipeline.php) or the dispatch cost adds up quickly. Queued listeners also defer the cost but break the "events are synchronous bookkeeping" assumption, so be deliberate.
Debugging. When something silently misbehaves, DB::listen() + Event::listen() + ->finally(fn () => logger(...)) gives you a complete trace of a run. Laravel Telescope covers events and queries if you have it enabled; the pipeline shows up as a sequence of events with no special integration needed.
Extension points. Subclass MichaelRubel\EnhancedPipeline\Pipeline, add your own helpers, and bind it in the container so app(Pipeline::class) resolves your flavour. Because every pipe is a plain invokable class, swapping an implementation is a one-line array change — the best reason to prefer this package over ad-hoc service methods.
Upgrading to 5.x. The 5.0.0 changelog only lists PHP 8.4 testing and Laravel 12 compatibility, so treat it as a dependency-bump major. Read the diff (4.0.1...5.0.0) anyway: if the bump is purely constraint-related the public API is unchanged and your withTransaction() / through() / thenReturn() code will be untouched, but if 5.x also raised the minimum PHP or dropped an older Laravel branch, that is the thing that will actually break your build. Pin the major version and test the upgrade on a branch that runs your pipeline integration tests.
Dependency risk is real but manageable — and improving. 55 stars and effectively zero known dependents means the community surface for bug reports is thin. Mitigations: pin the exact version in composer.json, read src/Pipeline.php once (it's tiny), and if the API surface you use is just withTransaction() + through() + then(), keeping a local copy of the class in your app is a legitimate exit strategy. Since the 4.x assessment was written the package has picked up a new outside contributor and shipped Laravel 12 support, which partially answers the "will this survive the next major Laravel version" question — but the release cadence is still one maintainer plus occasional PRs, so keep the pin and the local-copy escape hatch in your back pocket. The underlying Illuminate\Pipeline\Pipeline contract remains stable and the package is a thin wrapper over it, so the exit cost stays low.
How can I help you explore Laravel packages today?