Piper gives you a fluent, class-based pipeline: you push a payload through an ordered set of single-purpose classes ("pipes"/"links") and get a result back. In 3.0.0 the package also supports a proxied entry point, so you can talk to the chain through one object instead of wiring ->through([...])->then(...) at every call site.
Minimal setup:
composer require transprime-research/piper:^3.0
3.0.0 sets the minimum PHP version to 7.4. If you are still on 7.2/7.3 anywhere (local Docker image, CI matrix, a legacy service), you cannot install this version — check composer.json / your CI image before bumping.
First steps:
php artisan about or simply resolve the class from the container (app(\Transprime\Piper\Piper::class)).src/ first: the Piper class (the builder/proxy surface) and Link.php (the individual step). Those two files define the whole mental model; the README examples will make sense once you've skimmed them.composer.json for the supported Laravel/illuminate range — 3.0 is a major bump, so don't assume the 2.2 constraint still holds.tests/ for the canonical usage shape before inventing your own.First realistic use case: a fat controller action that validates, creates a model, dispatches side effects, and returns a response. Split it into pipes and let Piper own the sequencing:
use Transprime\Piper\Facades\Piper;
return Piper::make($request->validated())
->through([
ResolveCustomer::class,
CreateOrder::class,
ApplyDiscounts::class,
ChargeCard::class,
SendReceipt::class,
])
->then(fn (Order $order) => OrderResource::make($order));
Each pipe is a plain class with a handle($payload, $next) (or __invoke) method and gets constructor-injected dependencies from the container — no new, no manual wiring.
1. Pipes as single-responsibility classes. The value of the package is only realised if each link is small and testable. Pass a DTO, not $request->all():
final class ApplyDiscounts
{
public function __construct(private DiscountRepository $discounts) {}
public function handle(OrderPayload $payload, \Closure $next): mixed
{
$payload->order->apply($this->discounts->for($payload->customer));
return $next($payload);
}
}
2. Proxied calls (new in 3.0). Instead of repeating the chain definition, bind the configured piper once and call methods "through" it — the proxy resolves the target and passes the result down the configured links:
// AppServiceProvider
$this->app->bind('order.piper', fn ($app) => $app->make(Piper::class)
->through([ValidateOrder::class, PersistOrder::class, NotifyCustomer::class]));
// Anywhere
app('order.piper')->place($dto);
Treat this as the "facade for a workflow" pattern: one named, reusable chain per domain action. Verify the exact proxy semantics against src/ on 3.0 — PR #9 ("Proxied piper call") landed right before the release, and PR #13 touched Link.php, so the call surface and the link contract are the two things most likely to differ from 2.x examples you'll find in blogs.
3. Reusable chains via Link. If you need the same sub-sequence in several workflows (e.g. "authorise → reserve stock"), model it as a Link and compose it, rather than copy-pasting the classes into every through([...]). This keeps ordering in one place.
4. Short-circuiting. Return early from a pipe when there's nothing left to do, or throw a domain exception and let the global handler / a try-catch at the call site translate it into a response. Don't return false and rely on truthiness — the payload is the contract.
5. Transactions at the boundary, not inside pipes. A chain that writes to several tables should be wrapped by the caller:
$order = DB::transaction(fn () => Piper::make($dto)->through([...])->then(fn ($o) => $o));
Pipes stay unaware of persistence boundaries, so they remain reusable in jobs and tests.
6. Console / queue contexts. The same chain runs from a queued job or an artisan command — that's the main payoff over a controller-shaped service class. Keep pipes stateless so a job retry re-runs cleanly.
Link.php and PR #9 added proxying. Read the diff (compare/2.2.0...3.0.0) before upgrading — for a small package it's a ten-minute read and it's the only reliable changelog you'll get.composer update will fail rather than silently downgrade. Add the version constraint check to your Dockerfile/CI matrix in the same PR as the upgrade.->through([...]) snapshot) if the sequence encodes business rules.dd() into a proxy chain from a queue. Log at the top of each pipe with the payload's identifying field instead; a var_dump inside a proxied call gives you the proxy, not the resolved target — resolve app(YourPipe::class) directly when you need to inspect behaviour.BindingResolutionException mid-chain usually means a pipe's constructor type-hint isn't bound, not that Piper is broken. app(ThatPipe::class) in tinker isolates it in seconds.handle($payload, fn ($p) => $p) with a recording closure — no Piper instance needed, no Laravel app needed for the pure ones.Link over adding conditionals inside pipes.How can I help you explore Laravel packages today?