Skip to main content

Overview

The Filters Pipeline is a design pattern that keeps controllers clean by moving all query filtering logic (where, whereLike, whereHas, etc.) into dedicated, reusable filter classes.
This pattern eliminates messy controller methods filled with conditional where clauses based on request parameters.

Why Use the Pipeline Pattern?

Clean Controllers

No more cluttered if-else chains in controller methods

Reusable Filters

Share filters across index, export, and API endpoints

Testable

Each filter can be unit tested independently

Maintainable

Easy to add, modify, or remove filters without touching controllers

Architecture

The filter system consists of three components:

1. Filter Interface

Defines the contract all filters must follow:
app/Filters/Contracts/FilterInterface.php
namespace App\Filters\Contracts;

use Illuminate\Database\Eloquent\Builder;

interface FilterInterface
{
    public function apply(Builder $query): Builder;
}

2. Base QueryFilter

Abstract class that orchestrates the filtering pipeline:
app/Filters/Base/QueryFilter.php
namespace App\Filters\Base;

use Illuminate\Http\Request;
use Illuminate\Database\Eloquent\Builder;
use App\Filters\Contracts\FilterInterface;

abstract class QueryFilter implements FilterInterface
{
    protected Request $request;
    protected Builder $query;

    public function __construct(Request $request)
    {
        $this->request = $request;
    }

    /**
     * Apply all registered filters to the query
     */
    public function apply(Builder $query): Builder
    {
        $this->query = $query;

        foreach ($this->filters() as $key => $filterClass) {
            if ($this->request->filled($key)) {
                (new $filterClass($this->request))->apply($this->query);
            }
        }

        return $this->query;
    }

    /**
     * Map request keys to filter classes
     * 
     * @return array<string, class-string>
     */
    abstract protected function filters(): array;
}
The filters() method maps request parameter names to their corresponding filter class.

3. Module-Specific Filter Registry

Each module has its own filter registry:
app/Filters/Sales/SalesFilters/SaleFilters.php
namespace App\Filters\Sales\SalesFilters;

use App\Filters\Base\QueryFilter;

class SaleFilters extends QueryFilter
{
    protected function filters(): array
    {
        return [
            'search'       => SaleSearchFilter::class,
            'client_id'    => SaleClientFilter::class,
            'warehouse_id' => SaleWarehouseFilter::class,
            'payment_type' => SalePaymentTypeFilter::class,
            'tipo_pago_id' => SaleTipoPagoFilter::class,
            'status'       => SaleStatusFilter::class,
            'from_date'    => SaleDateFilter::class,
            'min_amount'   => SaleAmountRangeFilter::class,
        ];
    }
}

Individual Filter Classes

Each filter is a dedicated class that handles one specific filtering concern.

Simple Exact Match Filter

app/Filters/Sales/SalesFilters/SaleClientFilter.php
namespace App\Filters\Sales\SalesFilters;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use App\Filters\Contracts\FilterInterface;

class SaleClientFilter implements FilterInterface
{
    public function __construct(protected Request $request) {}

    public function apply(Builder $query): Builder
    {
        $value = $this->request->input('client_id');
        return $value ? $query->where('client_id', $value) : $query;
    }
}

Search Filter (Multiple Fields)

app/Filters/Sales/SalesFilters/SaleSearchFilter.php
namespace App\Filters\Sales\SalesFilters;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use App\Filters\Contracts\FilterInterface;

class SaleSearchFilter implements FilterInterface
{
    public function __construct(protected Request $request) {}

    public function apply(Builder $query): Builder
    {
        $value = $this->request->input('search');
        if (!$value) return $query;

        return $query->where(function($q) use ($value) {
            $q->where('number', 'like', "%{$value}%")
              ->orWhere('notes', 'like', "%{$value}%")
              ->orWhereHas('client', function($subQ) use ($value) {
                  $subQ->where('name', 'like', "%{$value}%");
              });
        });
    }
}
Always wrap multiple OR conditions in a where(function($q) {...}) closure to prevent query logic issues.

Date Range Filter

app/Filters/Sales/SalesFilters/SaleDateFilter.php
namespace App\Filters\Sales\SalesFilters;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use App\Filters\Contracts\FilterInterface;
use Illuminate\Support\Carbon;

class SaleDateFilter implements FilterInterface
{
    public function __construct(protected Request $request) {}

    public function apply(Builder $query): Builder
    {
        $from = $this->request->input('from_date');
        $to = $this->request->input('to_date');

        return $query
            ->when($from, function($q) use ($from) {
                return $q->whereDate('sale_date', '>=', Carbon::parse($from));
            })
            ->when($to, function($q) use ($to) {
                return $q->whereDate('sale_date', '<=', Carbon::parse($to));
            });
    }
}
This filter handles both from_date and to_date parameters, allowing flexible date range queries.

Select/Dropdown Filter

app/Filters/Sales/SalesFilters/SalePaymentTypeFilter.php
namespace App\Filters\Sales\SalesFilters;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use App\Filters\Contracts\FilterInterface;

class SalePaymentTypeFilter implements FilterInterface
{
    public function __construct(protected Request $request) {}

    public function apply(Builder $query): Builder
    {
        $value = $this->request->input('payment_type');
        return $value ? $query->where('payment_type', $value) : $query;
    }
}

Numeric Range Filter

app/Filters/Sales/SalesFilters/SaleAmountRangeFilter.php
namespace App\Filters\Sales\SalesFilters;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use App\Filters\Contracts\FilterInterface;

class SaleAmountRangeFilter implements FilterInterface
{
    public function __construct(protected Request $request) {}

    public function apply(Builder $query): Builder
    {
        $min = $this->request->input('min_amount');
        $max = $this->request->input('max_amount');

        return $query
            ->when($min, fn($q) => $q->where('total_amount', '>=', $min))
            ->when($max, fn($q) => $q->where('total_amount', '<=', $max));
    }
}

Usage in Controllers

Applying filters in a controller is clean and simple:
app/Http/Controllers/Sales/SaleController.php
public function index(Request $request)
{
    $visibleColumns = $request->input('columns', SaleTable::defaultDesktop());
    $perPage = $request->input('per_page', 10);

    // Apply filter pipeline
    $sales = (new SaleFilters($request))
        ->apply(Sale::query()->withIndexRelations())
        ->latest()
        ->paginate($perPage)
        ->withQueryString();

    $catalogs = $this->catalogService->getForFilters();

    if ($request->ajax()) {
        return view('sales.partials.table', [
            'items' => $sales,
            'visibleColumns' => $visibleColumns,
            'allColumns' => SaleTable::allColumns(),
        ])->render();
    }

    return view('sales.index', array_merge(
        [
            'items' => $sales,
            'visibleColumns' => $visibleColumns,
            'allColumns' => SaleTable::allColumns(),
        ],
        $catalogs
    ));
}
  1. Create a new SaleFilters instance with the current request
  2. Call apply() on a base query builder (with eager loaded relations)
  3. The pipeline checks each registered filter
  4. If the request has that parameter, the filter is applied
  5. Continue with pagination, sorting, etc.

Export with Same Filters

The beauty of this pattern: reuse filters for exports!
app/Http/Controllers/Sales/SaleController.php
public function export(Request $request)
{
    // Same filters as index!
    $query = (new SaleFilters($request))
        ->apply(Sale::query()->withIndexRelations());

    $fileName = 'reporte-ventas-' . now()->format('d-m-Y-H-i') . '.xlsx';

    return Excel::download(new SalesExport($query), $fileName);
}
Users get exactly the data they filtered on the screen, exported to Excel. No code duplication!

Request Parameters

The filter system expects query string parameters:
# Filter by client
GET /sales?client_id=5

# Filter by payment type
GET /sales?payment_type=cash

# Filter by status
GET /sales?status=completed

Creating a New Filter

Follow this process to add a new filter to any module:
1

Create the Filter Class

php artisan make:class Filters/Sales/SalesFilters/SaleUserFilter
2

Implement FilterInterface

namespace App\Filters\Sales\SalesFilters;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use App\Filters\Contracts\FilterInterface;

class SaleUserFilter implements FilterInterface
{
    public function __construct(protected Request $request) {}

    public function apply(Builder $query): Builder
    {
        $userId = $this->request->input('user_id');
        return $userId ? $query->where('user_id', $userId) : $query;
    }
}
3

Register in Filter Registry

app/Filters/Sales/SalesFilters/SaleFilters.php
protected function filters(): array
{
    return [
        'search'       => SaleSearchFilter::class,
        'client_id'    => SaleClientFilter::class,
        'user_id'      => SaleUserFilter::class, // ← Add this
        // ... other filters
    ];
}
4

Add to Catalog Service

app/Services/Sales/SalesServices/SaleCatalogService.php
public function getForFilters(): array
{
    return [
        'clients' => Client::select('id', 'name')->get(),
        'users' => User::select('id', 'name')->get(), // ← Add this
        // ... other catalogs
    ];
}
5

Update the View

Add the filter UI component to your index view:
<select name="user_id" class="form-select">
    <option value="">Todos los vendedores</option>
    @foreach($users as $user)
        <option value="{{ $user->id }}" {{ request('user_id') == $user->id ? 'selected' : '' }}>
            {{ $user->name }}
        </option>
    @endforeach
</select>

Filter Patterns & Examples

Use when filtering by ID or exact value:
public function apply(Builder $query): Builder
{
    $value = $this->request->input('warehouse_id');
    return $value ? $query->where('warehouse_id', $value) : $query;
}
Use for text search across multiple fields:
public function apply(Builder $query): Builder
{
    $search = $this->request->input('search');
    if (!$search) return $query;

    return $query->where(function($q) use ($search) {
        $q->where('name', 'like', "%{$search}%")
          ->orWhere('code', 'like', "%{$search}%")
          ->orWhere('description', 'like', "%{$search}%");
    });
}
Use when filtering through a relationship:
public function apply(Builder $query): Builder
{
    $categoryId = $this->request->input('category_id');
    if (!$categoryId) return $query;

    return $query->whereHas('product', function($q) use ($categoryId) {
        $q->where('category_id', $categoryId);
    });
}
Use for true/false filters:
public function apply(Builder $query): Builder
{
    if ($this->request->has('only_active')) {
        return $query->where('is_active', true);
    }
    return $query;
}
Use when filtering by multiple selected values:
public function apply(Builder $query): Builder
{
    $statuses = $this->request->input('statuses', []);
    if (empty($statuses)) return $query;

    return $query->whereIn('status', $statuses);
}

Testing Filters

Filters are easy to test in isolation:
tests/Unit/Filters/SaleClientFilterTest.php
use Tests\TestCase;
use App\Models\Sales\Sale;
use App\Filters\Sales\SalesFilters\SaleClientFilter;
use Illuminate\Http\Request;
use Illuminate\Foundation\Testing\RefreshDatabase;

class SaleClientFilterTest extends TestCase
{
    use RefreshDatabase;

    public function test_filters_by_client_id()
    {
        // Arrange
        $client1 = Client::factory()->create();
        $client2 = Client::factory()->create();
        
        Sale::factory()->create(['client_id' => $client1->id]);
        Sale::factory()->create(['client_id' => $client1->id]);
        Sale::factory()->create(['client_id' => $client2->id]);

        $request = Request::create('/', 'GET', ['client_id' => $client1->id]);
        $filter = new SaleClientFilter($request);

        // Act
        $query = $filter->apply(Sale::query());
        $results = $query->get();

        // Assert
        $this->assertCount(2, $results);
        $this->assertTrue($results->every(fn($sale) => $sale->client_id === $client1->id));
    }

    public function test_returns_all_when_no_filter()
    {
        // Arrange
        Sale::factory()->count(5)->create();
        $request = Request::create('/', 'GET');
        $filter = new SaleClientFilter($request);

        // Act
        $results = $filter->apply(Sale::query())->get();

        // Assert
        $this->assertCount(5, $results);
    }
}

Best Practices

One Filter, One Concern

Each filter class should handle exactly one filtering concern. Don’t mix date and amount filtering in one class.

Return Query Builder

Always return the $query object, even if no filter is applied.

Use Type Hints

Declare parameter and return types for better IDE support and type safety.

Null Safety

Always check if the request parameter exists before applying the filter.
Never modify the request inside a filter. Filters should be read-only operations on the query builder.

Benefits Summary

BenefitDescription
Separation of ConcernsFiltering logic is separate from controllers
ReusabilitySame filters for index, export, API, reports
TestabilityEach filter can be unit tested independently
ReadabilityController methods are clean and focused
MaintainabilityEasy to add, modify, or remove filters
FlexibilityCombine any number of filters dynamically

Architecture

Understand the overall system design

Service Layer

Learn about business logic services

Permissions

Authorization and security

Build docs developers (and LLMs) love