Pipeline Pattern in Laravel: Composable Reporting Filters

10 min read

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:

php
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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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.

Related articles

Existing system support

Need help with a live application?

I help companies improve live systems, clean up delivery workflows, and ship new features without adding avoidable complexity.

Comments (0)
Sign in to leave a comment

You need to be signed in to add a comment.

Login

Need someone to take responsibility for the next step?

Let’s talk about your project and define a scope that actually makes sense for your goals.