A reporting endpoint often begins with three harmless conditions: date range, status, and sort order. It becomes difficult not because conditionals are bad, but because each one acquires its own permissions, joins, edge cases, and tests. Soon the controller no longer says one clear thing: build the report this user may see, then apply requested filters in a known order.
Laravel's Pipeline gives that sequence a name. Each pipe receives a report
context, changes one concern on its query builder, and passes it on. It is not
a reason to create classes for a two-filter page. It is useful when report
rules have independent business names, their order matters, or several report
endpoints assemble different subsets of the same rules.
We will build a sales report filtered by payment status, date, customer, and
minimum total. It uses an allow-listed sort, eager loads exactly the relations
its resource needs, and always remains inside the authenticated account. The
important boundaries are just as valuable as the pipes: authorization is not a
pipe, a pipe never materialises a query, and a user-controlled SQL identifier
never goes directly into orderBy.
Start with the boundary, not the pipeline
This compact controller is a reasonable starting point, but it hides important policy decisions as it grows:
public function index(Request $request): AnonymousResourceCollection
{
$query = Order::query()->whereBelongsTo($request->user()->account);
if ($request->filled('status')) {
$query->where('status', $request->string('status'));
}
if ($request->filled('from')) {
$query->whereDate('paid_at', '>=', $request->date('from'));
}
if ($request->filled('sort')) {
$query->orderBy($request->string('sort'));
}
return OrderResource::collection($query->paginate());
}
The issue is not the three if statements. The request does not declare valid
input, sort is an unsafe SQL identifier, and there is no named home for the
next rule: finance may view refunds while sales staff may only view their
team's orders. Keep ordinary scopes for a small, stable screen. Introduce a
pipeline when a controller has become a change hotspot rather than merely
because an architectural diagram has a box called Pipeline.
Validate once at the HTTP boundary. This makes every downstream pipe work with a documented vocabulary rather than parsing request strings defensively.
<?php
declare(strict_types=1);
namespace App\Http\Requests;
use App\Models\Order;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
final class SalesReportRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('viewAny', Order::class);
}
/** @return array<string, array<int, mixed>> */
public function rules(): array
{
return [
'customer_id' => ['nullable', 'integer', 'exists:customers,id'],
'from' => ['nullable', 'date'],
'minimum_total' => ['nullable', 'integer', 'min:0'],
'status' => ['nullable', Rule::in(['paid', 'refunded'])],
'sort' => ['nullable', Rule::in(['paid_at', 'total_cents', 'number'])],
'direction' => ['nullable', Rule::in(['asc', 'desc'])],
];
}
}
The policy check belongs in the Form Request or route middleware because it decides whether someone may enter this capability at all. A pipe may narrow an already-authorized query, for example to a team, but must never be the only thing preventing access. A forgotten optional pipe should not become a data leak.
Carry a small, explicit report context
The context contains a planned query and validated filters—not the HTTP request,
a paginator, or loaded rows. That makes it reusable later by a queued export or
scheduled email. readonly keeps the builder identity and input stable; the
builder itself is deliberately mutated fluently by each pipe.
<?php
declare(strict_types=1);
namespace App\Reports;
use App\Models\Order;
use Illuminate\Database\Eloquent\Builder;
final readonly class SalesReport
{
/**
* @param Builder<Order> $query
* @param array<string, mixed> $filters
*/
public function __construct(
public Builder $query,
public array $filters,
) {}
}
Each pipe has exactly one query responsibility. Payment status is intentionally boring: the class name and isolated test boundary are its benefit.
<?php
declare(strict_types=1);
namespace App\Reports\Pipes;
use App\Reports\SalesReport;
use Closure;
final class FilterPaymentStatus
{
/** @param Closure(SalesReport): SalesReport $next */
public function handle(SalesReport $report, Closure $next): SalesReport
{
match ($report->filters['status'] ?? null) {
'paid' => $report->query->whereNotNull('paid_at'),
'refunded' => $report->query->whereNotNull('refunded_at'),
default => null,
};
return $next($report);
}
}
Date semantics belong in a named pipe too. A date selected in a UI normally means the entire local day, not midnight only. If reports operate across time zones, establish and document the report timezone before this point rather than letting individual filters guess it.
<?php
declare(strict_types=1);
namespace App\Reports\Pipes;
use App\Reports\SalesReport;
use Closure;
use Illuminate\Support\Carbon;
final class FilterPaidFromDate
{
/** @param Closure(SalesReport): SalesReport $next */
public function handle(SalesReport $report, Closure $next): SalesReport
{
if (($from = $report->filters['from'] ?? null) !== null) {
$report->query->where('paid_at', '>=', Carbon::parse($from)->startOfDay());
}
return $next($report);
}
}
Customer and money filters illustrate two quieter safety properties. The base
query, not an optional filter, enforces the account boundary. The customer pipe
only narrows it. Store money as integer cents, so minimum_total never depends
on locale-specific decimals or floating-point rounding.
<?php
declare(strict_types=1);
namespace App\Reports\Pipes;
use App\Reports\SalesReport;
use Closure;
final class FilterCustomer
{
/** @param Closure(SalesReport): SalesReport $next */
public function handle(SalesReport $report, Closure $next): SalesReport
{
if (($customerId = $report->filters['customer_id'] ?? null) !== null) {
$report->query->where('customer_id', $customerId);
}
return $next($report);
}
}
<?php
declare(strict_types=1);
namespace App\Reports\Pipes;
use App\Reports\SalesReport;
use Closure;
final class FilterMinimumTotal
{
/** @param Closure(SalesReport): SalesReport $next */
public function handle(SalesReport $report, Closure $next): SalesReport
{
if (($minimum = $report->filters['minimum_total'] ?? null) !== null) {
$report->query->where('total_cents', '>=', $minimum);
}
return $next($report);
}
}
Common report predicates deserve matching database indexes. For a frequent
account-level paid-date report, consider an index beginning with account_id
and then paid_at. Measure the actual plan before adding indexes; a pipeline
improves source-code composition but cannot compensate for an unindexed table.
Sorting is a public policy
Parameter binding protects values, not column identifiers. Therefore
orderBy($request->input('sort')) remains unsafe even if every where uses
bindings. Maintain an explicit public vocabulary, choose a stable default, and
add a deterministic tie-breaker so pagination does not shuffle equal rows.
<?php
declare(strict_types=1);
namespace App\Reports\Pipes;
use App\Reports\SalesReport;
use Closure;
final class ApplySort
{
private const SortableColumns = [
'paid_at' => 'paid_at',
'total_cents' => 'total_cents',
'number' => 'number',
];
/** @param Closure(SalesReport): SalesReport $next */
public function handle(SalesReport $report, Closure $next): SalesReport
{
$column = self::SortableColumns[$report->filters['sort'] ?? 'paid_at'];
$direction = $report->filters['direction'] ?? 'desc';
$report->query->orderBy($column, $direction)->orderByDesc('id');
return $next($report);
}
}
Validation controls the direction; the allow-list controls the column. They
are separate responsibilities. Sorting on a related value needs explicit SQL—a
join or subquery in its own pipe—not orderBy('customer.name') and a hope that
the database will infer an intended relationship.
Compose first; materialise once
The controller defines the report sequence in one place. It begins from a
mandatory account scope, places restrictions before client-directed filters,
then executes the finished query at the HTTP boundary. with() is safe in a
pipe because it changes query shape but does not fetch rows.
<?php
declare(strict_types=1);
namespace App\Reports\Pipes;
use App\Reports\SalesReport;
use Closure;
final class LoadReportRelations
{
/** @param Closure(SalesReport): SalesReport $next */
public function handle(SalesReport $report, Closure $next): SalesReport
{
$report->query->with(['customer:id,name', 'salesperson:id,name']);
return $next($report);
}
}
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Http\Requests\SalesReportRequest;
use App\Http\Resources\OrderResource;
use App\Models\Order;
use App\Reports\Pipes\ApplySort;
use App\Reports\Pipes\FilterCustomer;
use App\Reports\Pipes\FilterMinimumTotal;
use App\Reports\Pipes\FilterPaidFromDate;
use App\Reports\Pipes\FilterPaymentStatus;
use App\Reports\Pipes\LoadReportRelations;
use App\Reports\SalesReport;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Pipeline\Pipeline;
final class SalesReportController
{
public function __invoke(SalesReportRequest $request, Pipeline $pipeline): AnonymousResourceCollection
{
$report = $pipeline
->send(new SalesReport(
query: Order::query()->whereBelongsTo($request->user()->account),
filters: $request->validated(),
))
->through([
FilterPaymentStatus::class,
FilterPaidFromDate::class,
FilterCustomer::class,
FilterMinimumTotal::class,
ApplySort::class,
LoadReportRelations::class,
])
->thenReturn();
return OrderResource::collection($report->query->paginate());
}
}
Do not call get(), first(), cursor(), or count() inside a pipe to help a
later pipe. That materialises data too early, makes later filters ineffective,
and can cause a resource to lazily load one relationship per row. If an
aggregate is needed, express it with withCount, withSum, a select subquery,
or a deliberately separate query. The report resource should only access the
relations loaded above; development lazy-loading prevention makes omissions
visible early.
Test behaviour, not Pipeline internals
The highest-value test goes through the endpoint. It proves authorization, account isolation, filters, sorting, eager-load compatibility, and the resource response together. It guards against someone later moving the account scope to an optional pipe.
<?php
use App\Models\Account;
use App\Models\Customer;
use App\Models\Order;
use App\Models\User;
use Illuminate\Support\Carbon;
it('returns only paid orders from the authenticated account in requested order', function () {
$account = Account::factory()->create();
$user = User::factory()->for($account)->create();
$customer = Customer::factory()->for($account)->create();
$later = Order::factory()->for($account)->for($customer)->create([
'paid_at' => Carbon::parse('2026-01-20 10:00:00'),
'total_cents' => 25_000,
]);
$earlier = Order::factory()->for($account)->for($customer)->create([
'paid_at' => Carbon::parse('2026-01-10 10:00:00'),
'total_cents' => 15_000,
]);
Order::factory()->for($account)->for($customer)->create(['paid_at' => null]);
Order::factory()->for(Account::factory())->create(['paid_at' => Carbon::parse('2026-01-25')]);
$this->actingAs($user)
->getJson(route('reports.sales.index', [
'status' => 'paid',
'from' => '2026-01-01',
'minimum_total' => 10_000,
'sort' => 'paid_at',
'direction' => 'asc',
]))
->assertSuccessful()
->assertJsonPath('data.0.id', $earlier->id)
->assertJsonPath('data.1.id', $later->id)
->assertJsonCount(2, 'data');
});
Add focused pipe tests only for non-trivial semantics: records immediately
before and at a date boundary, a related-value sort subquery, or a safe default
when a SalesReport is constructed outside HTTP. Do not test that through()
was called or that every class was instantiated. Those assertions freeze
implementation mechanics while leaving report behaviour unprotected.
Pitfalls, alternatives, and when not to use it
The first pitfall is hidden ordering. If a pipe expects a join applied by an earlier pipe, they are not independent. Combine them or make that dependency a named query operation. The second is using Pipeline to conceal authorization. Policies and mandatory tenant/account scopes must remain obvious at the entry point. The third is a pipe that filters, executes, maps, and caches a report: it becomes a miniature service and defeats the pattern's clarity.
Use local Eloquent scopes for constraints that belong naturally to Order,
such as ->paid() or ->forReportPeriod($from, $to). Use a query object when
one stable report is reused widely and named methods are easier to navigate
than ten classes. A specification is better when rules must be stored or
combined as domain data outside an HTTP request. Use Pipeline for an
endpoint-specific sequence of independently meaningful transformations.
Most importantly, do not use it for two filters that will never grow, for a query whose operations cannot sensibly be selected or reordered, or to make a simple controller appear more sophisticated. Success is not the largest number of classes. It is a report whose access boundary, SQL shape, sorting policy, and single execution point remain obvious when the next requirement arrives.