kdabrow / filters
Filters for the Laravel API
Installs: 18
Dependents: 0
Suggesters: 0
Security: 0
Stars: 0
Watchers: 0
Forks: 0
Open Issues: 0
Type:package
pkg:composer/kdabrow/filters
Requires
- php: >=8.2.0
- illuminate/contracts: ^10.0|^11.0|^12.0
- illuminate/database: ^10.0|^11.0|^12.0
Requires (Dev)
- orchestra/testbench: ^8.0|^9.0|^10.0
This package is auto-updated.
Last update: 2026-02-01 12:37:05 UTC
README
A powerful filtering package for Laravel Eloquent models with support for dynamic queries, custom filters, nested conditions, ordering, and column aliases.
Features
- Laravel Model Integration: Direct filtering with
$model->filter() - URL-based Filtering:
GET /users?f[0][c]=name&f[0][o]=like&f[0][v]=John - Ordering Support:
GET /users?o[0][c]=created_at&o[0][v]=desc - Column Aliases: Hide real database columns from your API
- JSON Column Support: Filter and order by JSON paths (
data->type,data->meta->category) - Filter & Order Callbacks: Override query logic per column with closures or invocable classes
- Advanced Operators:
=,!=,>,<,>=,<=,like,nlike,in,nin,null,nnull - Nested Conditions: Complex grouping with AND/OR logic
- Security: Built-in input sanitization and SQL injection prevention
- Model Interfaces:
FilterableandOrderablefor fine-grained control
Upgrade Guide
Breaking Changes
allowedOperators are now enforced per column. Previously, the allowedOperators defined in the Filterable interface were silently ignored — any globally valid operator would pass through. Now they are strictly enforced. If your filters() config restricts a column to ['='], sending like for that column will be rejected.
Action required: Review your Filterable implementations and ensure that allowedOperators includes all operators your API consumers use. For example, if you had:
'name' => ['allowedOperators' => ['=']]
And your API consumers send like queries on name, update the config:
'name' => ['allowedOperators' => ['=', 'like']]
Models that do not implement Filterable (using $fillable only) are not affected — all global operators remain available.
Installation
composer require kdabrow/filters
The package includes Laravel integration out of the box. The service provider is auto-discovered.
Publish Configuration (Optional)
php artisan vendor:publish --tag=filters-config
Quick Start
Add the trait to your model and start filtering:
<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Kdabrow\Filters\HasFilters; class User extends Model { use HasFilters; protected $fillable = ['name', 'email', 'status']; }
// In your controller class UserController extends Controller { public function index() { // Automatically filters from request parameters return User::filter() ->with('profile') ->paginate(15); } }
URL Examples:
GET /users?f[0][c]=name&f[0][o]=like&f[0][v]=John GET /users?f[0][c]=status&f[0][o]==&f[0][v]=active&o[0][c]=created_at&o[0][v]=desc
Model Configuration
Basic Model Setup
The simplest setup uses the $fillable array for allowed columns:
class User extends Model { use HasFilters; protected $fillable = ['name', 'email', 'status', 'created_at']; // All fillable columns are available for filtering and ordering }
Advanced Model Configuration
For fine-grained control, implement the Filterable and Orderable interfaces:
<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Kdabrow\Filters\HasFilters; use Kdabrow\Filters\Filterable; use Kdabrow\Filters\Orderable; class User extends Model implements Filterable, Orderable { use HasFilters; protected $fillable = ['name', 'email', 'status']; public function filters(): array { return [ 'name' => ['allowedOperators' => ['=', 'like']], 'email' => ['allowedOperators' => ['=', 'like']], 'status' => ['allowedOperators' => ['=', 'in']], 'created_at' => ['allowedOperators' => ['>=', '<=']], ]; } public function orders(): array { return [ 'name' => ['allowedDirections' => ['asc', 'desc']], 'email' => ['allowedDirections' => ['asc', 'desc']], 'created_at' => ['allowedDirections' => ['desc']], // Only descending ]; } }
Column Aliases
Hide real database column names from your API using aliases:
class Product extends Model implements Filterable, Orderable { use HasFilters; protected $fillable = ['product_name', 'price', 'category_id', 'is_active']; public function filters(): array { return [ // API alias => Real database column 'name' => ['allowedOperators' => ['=', 'like'], 'column' => 'product_name'], 'price' => ['allowedOperators' => ['=', '>=', '<=']], 'category' => ['allowedOperators' => ['=', 'in'], 'column' => 'category_id'], 'active' => ['allowedOperators' => ['='], 'column' => 'is_active'], ]; } public function orders(): array { return [ 'name' => ['allowedDirections' => ['asc', 'desc'], 'column' => 'product_name'], 'price' => ['allowedDirections' => ['asc', 'desc']], 'newest' => ['allowedDirections' => ['desc'], 'column' => 'created_at'], ]; } }
API Usage with Aliases:
# Filter by 'name' (maps to product_name column) GET /products?f[0][c]=name&f[0][o]=like&f[0][v]=iPhone # Order by 'newest' (maps to created_at column) GET /products?o[0][c]=newest&o[0][v]=desc
JSON Column Filtering
Filter and order by JSON column paths using the column alias:
class Event extends Model implements Filterable, Orderable { use HasFilters; protected $fillable = ['name', 'data']; public function filters(): array { return [ 'type' => ['allowedOperators' => ['=', 'in'], 'column' => 'data->type'], 'priority' => ['allowedOperators' => ['=', '>=', '<='], 'column' => 'data->priority'], 'category' => ['allowedOperators' => ['='], 'column' => 'data->meta->category'], ]; } public function orders(): array { return [ 'type' => ['allowedDirections' => ['asc', 'desc'], 'column' => 'data->type'], 'priority' => ['allowedDirections' => ['asc', 'desc'], 'column' => 'data->priority'], ]; } }
# Filter by JSON path data->type GET /events?f[0][c]=type&f[0][o]==&f[0][v]=conference # Order by JSON path data->priority GET /events?o[0][c]=priority&o[0][v]=desc
Filter & Order Callbacks
Override the default query logic for specific columns using a callback or an invocable class:
class Order extends Model implements Filterable, Orderable { use HasFilters; public function filters(): array { return [ 'status' => [ 'allowedOperators' => ['='], 'callback' => OrderStatusFilter::class, ], 'total' => [ 'allowedOperators' => ['>=', '<='], 'callback' => function ($builder, $value, $operator, $queryType, $column) { $method = $queryType === 'or' ? 'orWhere' : 'where'; $builder->$method('total_cents', $operator, (int)$value * 100); }, ], ]; } public function orders(): array { return [ 'relevance' => [ 'allowedDirections' => ['desc'], 'callback' => function ($builder, $direction, $column) { $builder->orderByRaw('MATCH(title, description) AGAINST(?) DESC', [request('q')]); }, ], ]; } }
Invocable filter class:
class OrderStatusFilter { public function __invoke($builder, $value, $operator, $queryType, $column): void { $method = $queryType === 'or' ? 'orWhere' : 'where'; match ($value) { 'overdue' => $builder->$method('due_at', '<', now())->where('paid_at', null), default => $builder->$method('status', $operator, $value), }; } }
Callback signatures:
- Filter:
function($builder, $value, $operator, $queryType, $column): void - Order:
function($builder, $direction, $column): void
Controller Usage
Basic Usage
class UserController extends Controller { public function index() { // Automatically uses request parameters 'f' (filters) and 'o' (orders) return User::filter()->paginate(15); } }
Manual Parameters
public function customFilter() { $users = User::filter( input: [['c' => 'status', 'o' => '=', 'v' => 'active']], order: [['c' => 'created_at', 'v' => 'desc']] )->get(); return $users; }
Using Custom Filter Classes
public function advancedFilter() { return User::filter(name: UserFilter::class)->get(); }
Supported Operators
| Operator | Description | Example |
|---|---|---|
= |
Equal to | ['c' => 'status', 'o' => '=', 'v' => 'active'] |
!= |
Not equal to | ['c' => 'status', 'o' => '!=', 'v' => 'inactive'] |
> |
Greater than | ['c' => 'age', 'o' => '>', 'v' => '18'] |
< |
Less than | ['c' => 'age', 'o' => '<', 'v' => '65'] |
>= |
Greater than or equal | ['c' => 'price', 'o' => '>=', 'v' => '100'] |
<= |
Less than or equal | ['c' => 'price', 'o' => '<=', 'v' => '500'] |
like |
Contains (LIKE %value%) | ['c' => 'name', 'o' => 'like', 'v' => 'john'] |
nlike |
Does not contain | ['c' => 'email', 'o' => 'nlike', 'v' => 'temp'] |
in |
In array | ['c' => 'status', 'o' => 'in', 'v' => 'active,pending'] |
nin |
Not in array | ['c' => 'role', 'o' => 'nin', 'v' => 'banned,suspended'] |
null |
Is NULL | ['c' => 'deleted_at', 'o' => 'null'] |
nnull |
Is NOT NULL | ['c' => 'email_verified_at', 'o' => 'nnull'] |
URL Structure
Filter Parameters (f)
# Basic filter GET /users?f[0][c]=name&f[0][o]==&f[0][v]=John # Multiple filters (AND by default) GET /users?f[0][c]=name&f[0][o]=like&f[0][v]=John&f[1][c]=status&f[1][o]==&f[1][v]=active # OR condition GET /users?f[0][c]=role&f[0][o]==&f[0][v]=admin&f[1][c]=role&f[1][o]==&f[1][v]=moderator&f[1][t]=or
Order Parameters (o)
# Single column ascending (default) GET /users?o[0][c]=name # Single column descending GET /users?o[0][c]=created_at&o[0][v]=desc # Multiple columns GET /users?o[0][c]=name&o[0][v]=asc&o[1][c]=created_at&o[1][v]=desc
Advanced Features
Nested Conditions
Create complex grouped conditions:
$filters = [ [ // Group 1: (status=0 AND name='test' OR name='test 2') ['c' => 'status', 'o' => '=', 'v' => '0'], ['c' => 'name', 'o' => '=', 'v' => 'test'], ['c' => 'name', 'o' => '=', 'v' => 'test 2', 't' => 'or'], ], [ // Group 2: (status=1 AND name='test 3') ['c' => 'status', 'o' => '=', 'v' => '1'], ['c' => 'name', 'o' => '=', 'v' => 'test 3'], 't' => 'or' // Connect this group with OR ] ]; User::filter(input: $filters)->get();
Generated SQL:
WHERE (status = '0' AND name = 'test' OR name = 'test 2') OR (status = '1' AND name = 'test 3')
Custom Filter Classes
Create custom filter classes for complex filtering logic that goes beyond simple column comparisons:
<?php namespace App\Filters; use Kdabrow\Filters\Filter; class UserFilter extends Filter { protected function filterActive($builder) { if (! reqeust()->has('is_verified')) { return; } $builder->where('status', 'active') ->whereNotNull('verified_at'); } protected function filterAdmins($builder) { if (! reqeust()->has('is_admin')) { return; } $builder->whereIn('role', ['admin', 'super_admin']); } protected function filterPremium($builder) { if (! reqeust()->has('is_premium')) { return; } $builder->whereHas('subscription', function($query) { $query->where('type', 'premium') ->where('expires_at', '>', now()); }); } }
Auto-Discovery Usage:
The package automatically discovers filter classes based on your model name:
// For User model, it looks for App\Filters\UserFilter User::filter()->get();
Manual Class Specification:
// Explicitly specify the filter class User::filter(name: UserFilter::class)->get(); // Use a different filter class than the auto-discovered one User::filter(name: AdminUserFilter::class)->get(); // Pass an instance instead of class name $filter = new UserFilter(); User::filter(name: $filter)->get();
How Auto-Discovery Works:
- Takes your model class name (e.g.,
User,BlogPost) - Prepends the configured namespace (default:
App\Filters) - Appends
Filtersuffix - Checks if the class exists and extends the base
Filterclass - If found, instantiates and uses it; otherwise falls back to dynamic filtering
Filter Methods:
- Method names must start with
filter(e.g.,filterActive,filterPremium) - Each method receives the query
$builderas parameter - Methods are automatically called when the filter is applied
Configuration
The config/filters.php file allows customization:
return [ // Request parameter key for filters (default: 'f') 'key' => 'f', // Request parameter key for ordering (default: 'o') 'order_key' => 'o', // Default namespace for filter classes 'namespace' => 'App\\Filters', // Enable/disable auto-discovery of filter classes 'auto_discovery' => true, ];
Security Features
- Column Validation: Only defined columns (fillable or interface) are allowed
- Operator Validation: Only specified operators per column are accepted
- Direction Validation: Only allowed directions per column are accepted
- Input Sanitization: All values are sanitized to prevent SQL injection
- Column Sanitization: Column names are sanitized to prevent injection
Manual Usage (Without Models)
For non-Laravel projects or custom usage:
use Kdabrow\Filters\Filter; use Kdabrow\Filters\Ordering; $filters = [ ['c' => 'name', 'o' => 'like', 'v' => 'John'], ['c' => 'status', 'o' => '=', 'v' => 'active'], ]; $orders = [ ['c' => 'created_at', 'v' => 'desc'] ]; // Apply filters $filter = new Filter(); $filter->load($filters, $model); $filter->apply($queryBuilder); // Apply ordering $ordering = new Ordering(); $ordering->load($orders, $model); $ordering->apply($queryBuilder);
Complete Example
<?php namespace App\Http\Controllers; use App\Models\User; use Illuminate\Http\Request; class UserController extends Controller { public function index() { return User::filter() ->with(['posts', 'profile']) ->whereHas('posts', function ($query) { $query->where('published', true); }) ->paginate(request('per_page', 15)); } }
Request Examples:
# Search active users named John, ordered by creation date GET /users?f[0][c]=name&f[0][o]=like&f[0][v]=John&f[1][c]=status&f[1][o]==&f[1][v]=active&o[0][c]=created_at&o[0][v]=desc # Complex nested condition with ordering GET /users?f[0][0][c]=role&f[0][0][o]==&f[0][0][v]=admin&f[0][1][c]=status&f[0][1][o]==&f[0][1][v]=active&f[1][0][c]=role&f[1][0][o]==&f[1][0][v]=user&f[1][t]=or&o[0][c]=name&o[0][v]=asc