php
70 lines · 7 steps
Composable query filters with Laravel's Pipeline
Break a filtered index query into single-purpose classes and pass an Eloquent builder through them one stage at a time.
Explained by
highlit
1namespace App\Http\Filters;
2
3use Closure;
4use Illuminate\Database\Eloquent\Builder;
5
6class FilterByStatus
7{
8 public function handle(Builder $query, Closure $next): Builder
9 {
10 if ($status = request('status')) {
11 $query->whereIn('status', (array) $status);
12 }
13
14 return $next($query);
15 }
16}
17
18class FilterBySearch
19{
20 public function handle(Builder $query, Closure $next): Builder
21 {
22 if ($term = request('q')) {
23 $query->where(function (Builder $q) use ($term) {
24 $q->where('title', 'like', "%{$term}%")
25 ->orWhere('description', 'like', "%{$term}%");
26 });
27 }
28
29 return $next($query);
30 }
31}
32
33class FilterByPriceRange
34{
35 public function handle(Builder $query, Closure $next): Builder
36 {
37 $query
38 ->when(request('min_price'), fn (Builder $q, $min) => $q->where('price', '>=', $min))
39 ->when(request('max_price'), fn (Builder $q, $max) => $q->where('price', '<=', $max));
40
41 return $next($query);
42 }
43}
44
45namespace App\Http\Controllers;
46
47use App\Http\Filters\FilterByPriceRange;
48use App\Http\Filters\FilterBySearch;
49use App\Http\Filters\FilterByStatus;
50use App\Models\Product;
51use Illuminate\Support\Facades\Pipeline;
52
53class ProductController extends Controller
54{
55 public function index()
56 {
57 $products = Pipeline::send(Product::query())
58 ->through([
59 FilterByStatus::class,
60 FilterBySearch::class,
61 FilterByPriceRange::class,
62 ])
63 ->thenReturn()
64 ->latest()
65 ->paginate(15)
66 ->withQueryString();
67
68 return view('products.index', compact('products'));
69 }
70}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1The pipeline pattern turns tangled conditional query logic into a list of small, independently testable stages.
- 2Each filter mutates the shared Builder only when its request parameter is present, so absent filters are harmless no-ops.
- 3Because every stage receives and returns the same Builder, you can reorder or add filters without touching the controller's core flow.
Related explainers
php
<?php namespace App\Services;
Tagged cache invalidation in Laravel
caching
cache-tags
invalidation
Intermediate
5 steps
php
final class BitmapGrid { private SplFixedArray $cells;
A flat-array bitmap grid in PHP
data-structures
row-major-order
bitmap
Intermediate
7 steps
php
namespace App\Providers; use Illuminate\Database\Query\Builder as QueryBuilder; use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
Adding a whereActive macro in Laravel
macros
service-provider
query-builder
Intermediate
5 steps
php
<?php namespace App\Reminders;
Scheduling reminders across time zones in PHP
timezones
datetime
immutability
Intermediate
7 steps
php
<?php namespace App\Http\Middleware;
Verifying GitHub webhook signatures in Laravel
middleware
hmac
webhooks
Intermediate
5 steps
php
<?php namespace App\Models;
How Eloquent scopes model order status in Laravel
query scopes
state machine
eloquent
Intermediate
7 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/composable-query-filters-with-laravel-s-pipeline-explained-php-8f98/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.