zaengle/pipeline
Zaengle Pipeline is a lightweight Laravel package for building and running data pipelines. Chain reusable pipe classes to transform, validate, or filter data with a clean, expressive API, making complex multi-step workflows easier to organize and test.
Requirements check first. v6.0.0 is a compatibility release: it adds Laravel 13 and PHP 8.4 to the support matrix. Before upgrading, confirm your app is actually on Laravel 13 / PHP 8.4. If you're still on Laravel 10–12 or PHP 8.1–8.3, stay on the v5 line — the v5 → v6 major bump is the constraint change, not an API change.
composer require zaengle/pipeline
# publish the config (namespace/path defaults for generated classes)
php artisan vendor:publish --tag=pipeline-config
# scaffold something to run
php artisan make:pipeline ProcessOrder
php artisan make:pipe ValidateOrder
Where to look first:
config/pipeline.php — this is what decides where make:pipeline / make:pipe write files and what namespace they get. Set your app/Pipelines and app/Pipelines/Pipes paths here before generating anything, or you'll be moving files on day one.__invoke($payload, Closure $next). If you've used Laravel's own Illuminate\Pipeline\Pipeline, this is exactly that contract with naming and scaffolding on top.First use case: a validation/normalisation pipeline on an inbound request payload. Three pipes — trim & cast, validate, enrich — each independently unit-testable, then run them as one unit:
use Zaengle\Pipeline\Facades\Pipeline;
$order = Pipeline::pipe(ProcessOrder::class)
->send($request->validated())
->thenReturn();
That's the package's core value: named, discoverable, testable pipelines instead of an inline ->through([...]) array buried in a controller.
One pipe, one job. The single biggest determinant of whether this package helps or hurts is pipe granularity. A pipe should take a payload, do one thing, and hand it on. If a pipe needs a comment explaining a "phase", it's two pipes.
Payload as a DTO, not an array. Arrays are fine for a two-pipe pipeline and miserable by pipe six — every pipe has to know the key names, and refactors are untyped and ungreppable. Use a readonly value object or a spatie/laravel-data object and let pipes return modified copies. Type-hint the payload in each __invoke and PHP will catch ordering bugs for you.
Constructor injection is your integration point. Pipes are resolved through the container, so dependencies arrive via the constructor and payload mutation stays the only concern of __invoke:
final class ChargeCard
{
public function __construct(private PaymentGateway $gateway) {}
public function __invoke(OrderPayload $payload, Closure $next)
{
$payload = $payload->withCharge($this->gateway->charge($payload->total));
return $next($payload);
}
}
Compose pipelines rather than growing them. For any pipeline past ~6 pipes, split into sub-pipelines (e.g. ValidateOrder, FulfillOrder) and have the parent pipeline reference them as steps. You keep readable class names and get a per-stage test surface.
Conditional steps belong in the pipeline class, not inside pipes. If a pipe is a no-op in half your flows, gate it in the pipeline definition — a pipe that's sometimes skipped is a pipe whose contract is unclear, and it makes the ordering array lie about what actually runs.
Test at two levels, always:
$next closure (or fn ($p) => $p) and assert the payload it produces. No container, no pipeline needed. This is where 80% of your tests should live.Boundaries — when not to reach for this. Pipelines are in-process and synchronous. If a step needs retries, its own failure queue, or long runtime, that's a queued job, not a pipe. Conversely, don't wrap a queued job in a pipeline just to "chain" — use Laravel's job chaining. The package shines on deterministic, in-request transformation and orchestration.
Side-effect ordering. Pipes that write to the DB, send mail, or hit third-party APIs make the pipeline's order load-bearing. Put validation and pure transformation before any side-effecting pipe, and wrap side-effecting pipelines in DB::transaction() at the call site — the pipeline itself doesn't offer transactional rollback.
The classic bug: forgetting to return the chain. A pipe that calls $next($payload) but doesn't return it silently terminates the remainder of the pipeline and hands back null. Every pipe should end with return $next($payload);. If a pipeline mysteriously stops after pipe three, this is the first thing to check.
Return-value semantics matter. Laravel's pipeline distinguishes then() (you get a closure over the carried value) from thenReturn() (you get the carried value). Mixing them up produces a Closure where you expected a payload — the error surfaces far from the cause. Pick one style per codebase and stick to it.
Payload type drift. If pipe A returns an array and pipe B is typed for a DTO, you get a TypeError at runtime, not at pipeline definition time. There's no static check across the pipe array. The DTO approach above, plus a typed $next in each pipe, is what keeps this from becoming a whack-a-mole exercise.
Config caching after scaffolding. make:pipeline / make:pipe rely on the published config for target paths and namespaces. If you've run php artisan config:cache and then edit config/pipeline.php, generated classes land in stale locations until you re-cache. Standard Laravel footgun, but it bites here because the config governs codegen, not just runtime behaviour.
Upgrading v5 → v6. The release notes are a support-matrix change only — no API, no deprecations listed. Practical upgrade path:
composer require zaengle/pipeline:^6.0.Illuminate\Support helpers) rather than in the package itself — failures will look like they're in your pipes, which is expected.^5.0 in any package or service you maintain that still supports Laravel ≤12; the major bump means Composer will refuse to co-install v6 there.Extension points worth knowing. Generated classes are plain PHP with no base-class inheritance requirements in the common case — which means you can hand-write a pipeline, or add your own base pipeline class with shared helpers (logging, timing, metrics), without fighting the package. If you find yourself repeating the same three pipes across pipelines, extract a sub-pipeline rather than a base class; it stays greppable and testable.
Debugging a pipeline that "does nothing". Add a temporary pipe at the head of the array that dumps and returns $next($payload), or log at the top of each __invoke. Because pipes are individually resolvable, you can also drop into tinker and invoke a single pipe against a fixture payload — often faster than reasoning about the whole chain.
Watch the CI matrix on upgrade. v6.0.0 added a CI matrix covering Laravel 13 / PHP 8.4. If you consume this in a library, mirror those pairs in your own matrix so a future package release doesn't quietly drop the version you still ship.
How can I help you explore Laravel packages today?