DEV Community

Cover image for Stop Writing Filter Logic in Controllers — Meet Filterable for Laravel
Abdalrhman Emad Saad
Abdalrhman Emad Saad

Posted on

Stop Writing Filter Logic in Controllers — Meet Filterable for Laravel

The Complete Guide to Filterable: Clean, Scalable Eloquent Filtering for Laravel

If you've ever built a Laravel API with search and filtering, you've probably written something like this:

$query = Post::query();

if ($request->status) {
    $query->where('status', $request->status);
}

if ($request->title) {
    $query->where('title', 'like', "%{$request->title}%");
}

if ($request->created_after) {
    $query->where('created_at', '>=', $request->created_after);
}

// ...10 more conditions
Enter fullscreen mode Exit fullscreen mode

It works. But as the project grows, this logic spreads across controllers, gets duplicated, and becomes nearly impossible to test in isolation.

Filterable solves this by moving all filtering logic into dedicated, testable filter classes — and giving you four different engines to structure that logic however fits your use case.


Installation

composer require kettasoft/filterable
Enter fullscreen mode Exit fullscreen mode
php artisan vendor:publish --provider="Kettasoft\Filterable\Providers\FilterableServiceProvider" --tag="config"
Enter fullscreen mode Exit fullscreen mode

Your First Filter

Generate a filter class using the CLI:

php artisan filterable:make-filter PostFilter --filters=title,status
Enter fullscreen mode Exit fullscreen mode

This creates App\Http\Filters\PostFilter. Open it and define your filter methods:

namespace App\Http\Filters;

use Kettasoft\Filterable\Filterable;
use Kettasoft\Filterable\Support\Payload;

class PostFilter extends Filterable
{
    protected $filters = ['status', 'title'];

    protected function title(Payload $payload)
    {
        return $this->builder->where('title', 'like', $payload->asLike('both'));
    }

    protected function status(Payload $payload)
    {
        return $this->builder->where('status', $payload->value);
    }
}
Enter fullscreen mode Exit fullscreen mode

Apply it in your controller:

public function index()
{
    return Post::filter(PostFilter::class)->paginate();
}
Enter fullscreen mode Exit fullscreen mode

Now GET /posts?status=active&title=laravel automatically triggers the right methods. No if-statements, no manual $request->has() checks.


Binding the Filter to Your Model

If you always use the same filter class for a model, you can bind it directly:

use Kettasoft\Filterable\Traits\HasFilterable;

class Post extends Model
{
    use HasFilterable;

    protected $filterable = PostFilter::class;
}
Enter fullscreen mode Exit fullscreen mode

Now your controller becomes a single line:

return Post::filter()->paginate();
Enter fullscreen mode Exit fullscreen mode

Choosing an Engine

This is where Filterable stands apart from other filtering packages. Instead of forcing one approach, it ships with four engines. You pick the one that matches how your frontend sends data.

Engine Best For Example
Invokable Custom logic per field ?status=active&title=laravel
Ruleset Operator-based API queries ?filter[title][like]=laravel
Expression Ruleset + nested relations ?filter[author.name][like]=ahmed
Tree Complex AND/OR JSON logic { "and": [...] }

Let's go through each one.


1. Invokable Engine

The default engine. Each request key maps to a method in your filter class. Clean, explicit, and easy to understand.

class PostFilter extends Filterable
{
    protected $filters = ['status', 'title', 'created_at'];

    protected function status(Payload $payload)
    {
        return $this->builder->where('status', $payload->value);
    }

    protected function title(Payload $payload)
    {
        return $this->builder->where('title', 'like', $payload->asLike('both'));
    }

    protected function created_at(Payload $payload)
    {
        return $this->builder->whereDate('created_at', '>=', $payload->value);
    }
}
Enter fullscreen mode Exit fullscreen mode

You can also use PHP 8 Annotations to add behavior per method without writing extra code:

use Kettasoft\Filterable\Annotations\Cast;
use Kettasoft\Filterable\Annotations\SkipIf;
use Kettasoft\Filterable\Annotations\Between;

class PostFilter extends Filterable
{
    protected $filters = ['status', 'created_at'];

    #[Cast('integer')]
    #[DefaultValue(1)]
    protected function status(Payload $payload)
    {
        return $this->builder->where('status', $payload->value);
    }

    #[SkipIf('auth()->guest()')]
    #[Between(min: '2020-01-01', max: 'now')]
    protected function created_at(Payload $payload)
    {
        return $this->builder->whereDate('created_at', $payload->value);
    }
}
Enter fullscreen mode Exit fullscreen mode

Available annotations: #[Authorize] #[SkipIf] #[Cast] #[Sanitize] #[Trim] #[DefaultValue] #[MapValue] #[Explode] #[Required] #[In] #[Between] #[Regex] #[Scope]


2. Ruleset Engine

Ideal for REST APIs where the frontend sends structured filter parameters with explicit operators.

GET /posts?filter[status]=published
GET /posts?filter[title][like]=%laravel%
GET /posts?filter[views][gte]=100
GET /posts?filter[id][in][]=1&filter[id][in][]=2
GET /posts?filter[price][between][]=10&filter[price][between][]=50
Enter fullscreen mode Exit fullscreen mode

Define your filter class with allowed fields and operators:

class PostFilter extends Filterable
{
    protected $allowedFields = ['status', 'title', 'views', 'created_at'];

    protected $allowedOperators = ['eq', 'like', 'gte', 'lte', 'in', 'between'];
}
Enter fullscreen mode Exit fullscreen mode

No methods needed — the engine handles the query building automatically.

Supported operators: eq neq gt gte lt lte like nlike in nin between null notnull


3. Expression Engine

Everything the Ruleset engine does, plus the ability to filter through nested Eloquent relationships using dot notation.

GET /posts?filter[author.profile.name][like]=ahmed
GET /posts?filter[status]=active&filter[category.name]=laravel
Enter fullscreen mode Exit fullscreen mode
use Kettasoft\Filterable\Facades\Filterable;

Filterable::create()
    ->useEngine('expression')
    ->allowedFields(['status', 'title'])
    ->allowRelations([
        'author.profile' => ['name', 'bio']
    ])
    ->filter(Post::query());
Enter fullscreen mode Exit fullscreen mode

The engine automatically resolves the relation path and applies whereHas queries — no manual join logic needed.


4. Tree Engine

For advanced use cases where the frontend needs to build complex AND/OR logic — think filter builders in admin panels or reporting tools.

The request sends a nested JSON tree:

{
  "filter": {
    "and": [
      { "field": "status", "operator": "eq", "value": "active" },
      {
        "or": [
          { "field": "views", "operator": "gte", "value": 1000 },
          { "field": "featured", "operator": "eq", "value": true }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

The engine recursively translates this into Eloquent where / orWhere groups. It also supports depth limiting to protect against overly complex queries:

// config/filterable.php
'tree' => [
    'depth_limit' => 3,
    'normalize_keys' => true,
]
Enter fullscreen mode Exit fullscreen mode

Validation & Sanitization

Input handling lives inside the filter class, not scattered across controllers or form requests.

Validation uses Laravel's native rules format:

class PostFilter extends Filterable
{
    protected $rules = [
        'status' => ['required', 'string', 'in:active,pending,archived'],
        'title'  => ['sometimes', 'string', 'max:100'],
    ];
}
Enter fullscreen mode Exit fullscreen mode

If validation fails, a ValidationException is thrown automatically.

Sanitization runs before validation, via dedicated sanitizer classes:

class PostFilter extends Filterable
{
    protected $sanitizers = [
        TrimSanitizer::class,           // global — runs on all fields
        'title' => [
            StripTagsSanitizer::class,
            CapitalizeSanitizer::class,
        ],
    ];
}
Enter fullscreen mode Exit fullscreen mode

A sanitizer implements the Sanitizable interface:

class TrimSanitizer implements Sanitizable
{
    public function sanitize(mixed $value): mixed
    {
        return is_string($value) ? trim($value) : $value;
    }
}
Enter fullscreen mode Exit fullscreen mode

Execution order: sanitize → validate → filter.


Authorization

Protect a filter class based on roles or permissions by defining an authorize() method:

class AdminFilter extends Filterable
{
    public function authorize(): bool
    {
        return auth()->user()?->isAdmin() ?? false;
    }
}
Enter fullscreen mode Exit fullscreen mode

If authorize() returns false, a FilterAuthorizationException is thrown before any filtering runs. Per-method authorization is also available via the #[Authorize] annotation in the Invokable engine.


Sorting

Define which fields are sortable via $sortable:

class PostFilter extends Filterable
{
    protected $sortable = ['created_at', 'views', 'title'];
}
Enter fullscreen mode Exit fullscreen mode
GET /posts?sort=-created_at   # descending
GET /posts?sort=views         # ascending
Enter fullscreen mode Exit fullscreen mode

Caching

Filterable has a full caching system built into the filter pipeline.

// Cache for 1 hour
Post::filter()->cache(3600)->get();

// User-scoped — each user gets their own cache entry
Post::filter()->cache(1800)->scopeByUser()->get();

// Tagged cache — easy to invalidate by group
Post::filter()->cache(3600)->cacheTags(['posts', 'content'])->get();
Post::flushCacheByTagsStatic(['posts']);

// Conditional caching
Post::filter()->cacheWhen(!auth()->user()->isAdmin(), 3600)->get();

// Reusable cache profiles defined in config
Report::filter()->cacheProfile('heavy_reports')->get();
Enter fullscreen mode Exit fullscreen mode

Enable auto-invalidation in config/filterable.php and cache is cleared automatically when a model is created, updated, or deleted:

'auto_invalidate' => [
    'enabled' => true,
    'models' => [
        \App\Models\Post::class => ['posts', 'content'],
    ],
],
Enter fullscreen mode Exit fullscreen mode

CLI Reference

# Create a new filter class
php artisan filterable:make-filter PostFilter --filters=title,status

# Discover and register all filter classes in your app
php artisan filterable:discover

# List all registered filters
php artisan filterable:list

# Test a filter with a sample payload
php artisan filterable:test PostFilter --payload='{"status":"active"}'

# Inspect a filter class (engine, fields, rules, sanitizers...)
php artisan filterable:inspect PostFilter
Enter fullscreen mode Exit fullscreen mode

Wrapping Up

Filterable gives you a structured, testable way to handle Eloquent filtering without cluttering your controllers. Whether you need simple key-value filters or complex nested AND/OR trees, there's an engine for it — and they all share the same validation, sanitization, authorization, caching, and sorting infrastructure.

Links:


Built by Kettasoft. MIT Licensed.

Top comments (0)