Endpoint raportowy często zaczyna się od trzech niewinnych warunków: zakresu dat, statusu i kolejności sortowania. Staje się trudny nie dlatego, że warunki są złe, lecz dlatego, że każdy zyskuje własne uprawnienia, joiny, przypadki brzegowe i testy. Wkrótce kontroler nie opowiada już jednej czytelnej historii: zbuduj raport, który użytkownik może zobaczyć, a następnie zastosuj żądane filtry w znanej kolejności.
Laravelowy Pipeline nadaje tej sekwencji nazwę. Każdy pipe otrzymuje kontekst
raportu, zmienia jedną odpowiedzialność buildera zapytania i przekazuje go
dalej. Nie jest to powód, aby tworzyć klasy dla strony z dwoma filtrami. Ma sens,
gdy reguły raportu mają niezależne nazwy biznesowe, ich kolejność jest ważna
albo kilka endpointów raportowych składa różne podzbiory tych samych reguł.
Zbudujemy raport sprzedaży filtrowany po statusie płatności, dacie, kliencie i
minimalnej wartości. Użyje allow-listy sortowania, eager loadingu dokładnie
tych relacji, których potrzebuje Resource, i zawsze pozostanie wewnątrz konta
zalogowanego użytkownika. Równie ważne jak pipe'y są granice: autoryzacja nie
jest pipe'em, pipe nie materializuje zapytania, a identyfikator SQL sterowany
przez użytkownika nigdy nie trafia bezpośrednio do orderBy.
Zacznij od granicy, nie od pipeline'u
Ten zwięzły kontroler jest rozsądnym punktem startowym, ale wraz z rozwojem ukrywa ważne decyzje dotyczące polityki:
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());
}
Problemem nie są trzy instrukcje if. Request nie deklaruje poprawnych danych
wejściowych, sort jest niebezpiecznym identyfikatorem SQL, a kod nie ma
nazwanego miejsca na następną regułę: dział finansów może oglądać zwroty, gdy
handlowiec widzi jedynie zamówienia swojego zespołu. Dla małego, stabilnego
ekranu zachowaj zwykłe scope'y. Wprowadź pipeline, gdy kontroler stał się
punktem zapalnym zmian, a nie wyłącznie dlatego, że na diagramie architektury
istnieje prostokąt o nazwie Pipeline.
Waliduj raz, na granicy HTTP. Dzięki temu każdy dalszy pipe pracuje z udokumentowanym słownikiem zamiast defensywnie parsować łańcuchy z requestu.
<?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'])],
];
}
}
Kontrola policy należy do Form Requestu albo middleware routingu, bo decyduje, czy ktoś w ogóle może wejść do tej funkcji. Pipe może zawęzić zapytanie już autoryzowanego użytkownika, na przykład do zespołu, ale nie może być jedyną barierą dostępu. Pominięty opcjonalny pipe nie może stać się wyciekiem danych.
Przenoś mały, jawny kontekst raportu
Kontekst zawiera zaplanowane zapytanie i zwalidowane filtry — nie request HTTP,
paginator ani załadowane wiersze. Dzięki temu można go później wykorzystać w
eksporcie kolejkowym lub zaplanowanym e-mailu. readonly utrzymuje tożsamość
buildera i danych wejściowych; sam builder jest celowo mutowany płynnie przez
każdy 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,
) {}
}
Każdy pipe ma dokładnie jedną odpowiedzialność dotyczącą zapytania. Status płatności jest celowo zwyczajny: korzyścią jest nazwa klasy i odizolowana granica testu, nie sprytny kod.
<?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);
}
}
Semantyka daty również należy do nazwanego pipe'a. Data wybrana w UI zwykle oznacza cały lokalny dzień, nie samą północ. Gdy raporty działają w wielu strefach czasowych, ustal i udokumentuj strefę raportu przed tym punktem, zamiast pozwalać poszczególnym filtrom zgadywać.
<?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);
}
}
Filtry klienta i kwoty pokazują dwie mniej oczywiste własności bezpieczeństwa.
Zapytanie bazowe, a nie opcjonalny filtr, egzekwuje granicę konta. Pipe klienta
jedynie ją zawęża. Przechowuj pieniądze jako całkowite centy, aby
minimum_total nie zależało od lokalnych separatorów dziesiętnych ani
zaokrągleń floatów.
<?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);
}
}
Typowe predykaty raportu zasługują na odpowiadające im indeksy bazy danych.
Dla częstego raportu opłaconych zamówień na poziomie konta rozważ indeks
zaczynający się od account_id, a następnie paid_at. Przed dodaniem indeksu
zmierz rzeczywisty plan wykonania; pipeline poprawia kompozycję kodu źródłowego,
ale nie naprawi tabeli bez indeksów.
Sortowanie jest publiczną polityką
Parametryzacja chroni wartości, nie identyfikatory kolumn. Dlatego
orderBy($request->input('sort')) pozostaje niebezpieczne, nawet jeżeli każde
where korzysta z bindowania. Utrzymuj jawny publiczny słownik, wybierz
stabilne domyślne sortowanie i dodaj deterministyczny tie-breaker, aby
paginacja nie tasowała wierszy o równych wartościach.
<?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);
}
}
Walidacja kontroluje kierunek; allow-lista kontroluje kolumnę. To osobne
odpowiedzialności. Sortowanie po wartości relacji wymaga jawnego SQL — joina
albo subquery we własnym pipe'ie — nie orderBy('customer.name') i nadziei, że
baza danych odgadnie właściwą relację.
Najpierw skomponuj, materializuj tylko raz
Kontroler określa sekwencję raportu w jednym miejscu. Zaczyna od obowiązkowego
scope'u konta, umieszcza ograniczenia przed filtrami sterowanymi przez klienta,
a następnie wykonuje gotowe zapytanie na granicy HTTP. with() jest bezpieczne
w pipe'ie, ponieważ zmienia kształt zapytania, ale nie pobiera wierszy.
<?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());
}
}
Nie wywołuj get(), first(), cursor() ani count() wewnątrz pipe'a, aby
pomóc następnemu krokowi. To materializuje dane za wcześnie, unieważnia
późniejsze filtry i może sprawić, że Resource leniwie załaduje jedną relację na
każdy wiersz. Gdy potrzebujesz agregatu, wyraź go przez withCount, withSum,
subquery selecta albo świadomie osobne zapytanie. Resource raportu powinien
korzystać wyłącznie z relacji załadowanych powyżej; blokowanie lazy loadów w
środowisku developerskim wcześnie ujawni pominięcia.
Testuj zachowanie, nie wnętrze Pipeline
Największą wartość ma test przechodzący przez endpoint. Razem sprawdza autoryzację, izolację kont, filtry, sortowanie, zgodność eager loadów i odpowiedź Resource'a. Chroni przed późniejszym przeniesieniem scope'u konta do opcjonalnego pipe'a.
<?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');
});
Dodaj skupione testy pipe'ów tylko dla nietrywialnej semantyki: rekordów tuż
przed i dokładnie na granicy daty, subquery sortującego po relacji albo
bezpiecznego domyślnego zachowania, gdy SalesReport powstaje poza HTTP. Nie
testuj, czy wywołano through(), ani czy utworzono każdą klasę. Takie asercje
zamrażają mechanikę implementacji, pozostawiając zachowanie raportu bez ochrony.
Pułapki, alternatywy i kiedy go nie używać
Pierwszą pułapką jest ukryta zależność kolejności. Gdy pipe oczekuje joina założonego przez wcześniejszy pipe, nie są niezależne. Połącz je albo uczyń tę zależność nazwaną operacją query. Drugą jest użycie Pipeline do ukrycia autoryzacji. Policies i obowiązkowe scope'y tenantów lub kont muszą pozostać widoczne w punkcie wejścia. Trzecia to pipe filtrujący, wykonujący, mapujący i cache'ujący raport: staje się małym serwisem i niszczy klarowność wzorca.
Użyj lokalnych scope'ów Eloquent dla ograniczeń naturalnie należących do
Order, takich jak ->paid() albo ->forReportPeriod($from, $to). Użyj query
objectu, gdy jeden stabilny raport jest szeroko używany, a nazwane metody są
łatwiejsze do odnalezienia niż dziesięć klas. Specification będzie lepsza, gdy
reguły muszą być przechowywane lub łączone jako dane domenowe poza requestem
HTTP. Pipeline wybierz dla specyficznej dla endpointu sekwencji niezależnie
znaczących transformacji.
Przede wszystkim nie używaj go dla dwóch filtrów, które nigdy nie urosną, dla zapytania, którego operacji nie da się sensownie wybierać ani porządkować, ani po to, aby prosty kontroler wydawał się bardziej zaawansowany. Sukcesem nie jest największa liczba klas. Jest nim raport, którego granica dostępu, kształt SQL, polityka sortowania i pojedynczy punkt wykonania pozostają oczywiste, gdy przychodzi następne wymaganie.