pradeepdev001 / laravel-smart-filter
A powerful, elegant, and extensible Eloquent filtering package for Laravel 10, 11 & 12.
Package info
github.com/pradeepdev001/laravel-smart-filter
pkg:composer/pradeepdev001/laravel-smart-filter
Requires
- php: ^8.2
- illuminate/contracts: ^10.0 || ^11.0 || ^12.0
- illuminate/database: ^10.0 || ^11.0 || ^12.0
- illuminate/http: ^10.0 || ^11.0 || ^12.0
- illuminate/pipeline: ^10.0 || ^11.0 || ^12.0
- illuminate/support: ^10.0 || ^11.0 || ^12.0
Requires (Dev)
- larastan/larastan: ^2.0 || ^3.0
- laravel/framework: ^10.0 || ^11.0 || ^12.0
- laravel/pint: ^1.0
- orchestra/testbench: ^8.0 || ^9.0 || ^10.0
- pestphp/pest: ^2.0 || ^3.0
- pestphp/pest-plugin-laravel: ^2.0 || ^3.0
- phpstan/phpstan: ^1.0 || ^2.0
This package is auto-updated.
Last update: 2026-08-08 17:06:29 UTC
README
A powerful, elegant, and extensible Eloquent filtering package for Laravel 10, 11 & 12.
Stop writing repetitive when() chains. Let your URL do the talking.
// Before User::query() ->when($request->status, fn ($q, $v) => $q->where('status', $v)) ->when($request->input('age>'), fn ($q, $v) => $q->where('age', '>', $v)) ->when($request->country, fn ($q, $v) => $q->whereIn('country', explode(',', $v))) ->whereHas('posts', fn ($q) => $q->where('status', $request->posts_status)) ->orderBy('created_at', 'desc') ->paginate(); // After User::smartFilter()->paginate();
Requirements
- PHP 8.2+
- Laravel 10, 11, or 12
Installation
composer require pradeepdev001/laravel-smart-filter
The service provider and facade are registered automatically via Laravel's package discovery.
Publish the config (optional):
php artisan vendor:publish --tag=smart-filter-config
Quick Start
Add the Filterable trait to any Eloquent model:
use Pradeepdev\SmartFilter\Traits\Filterable; class User extends Model { use Filterable; // Whitelist filterable fields (empty = all allowed) protected array $filterable = ['name', 'email', 'status', 'age', 'country']; // Fields searched by ?search= protected array $searchable = ['name', 'email']; // Protect sensitive columns protected array $filterIgnore = ['password', 'remember_token']; // Map request params to real columns protected array $filterAliases = ['city' => 'address_city']; }
Then in your controller:
public function index(): JsonResponse { return User::smartFilter()->paginate(); }
Or chain on an existing query:
User::where('tenant_id', auth()->id())->smartFilter()->paginate();
Filter Syntax
Equals
GET /users?status=active
GET /users?email=alice@example.com
Not Equals
GET /users?status!=inactive
Comparisons
GET /users?age>25
GET /users?price>=100
GET /users?age<40
GET /users?price<=500
LIKE (contains)
GET /users?name~john → WHERE name LIKE '%john%'
GET /users?name!~john → WHERE name NOT LIKE '%john%'
IN / NOT IN
GET /users?country=in(india,usa,uk)
GET /users?country=not_in(banned_country)
BETWEEN / NOT BETWEEN
GET /users?price=between(100,500)
GET /users?price=not_between(0,99)
NULL checks
GET /users?deleted_at=null → WHERE deleted_at IS NULL
GET /users?deleted_at=not_null → WHERE deleted_at IS NOT NULL
Boolean
GET /users?is_active=true
GET /users?is_active=false
GET /users?is_active=1
GET /users?is_active=0
Sorting
Prefix with - for descending. Comma-separate for multiple columns.
GET /users?sort=name → ORDER BY name ASC
GET /users?sort=-created_at → ORDER BY created_at DESC
GET /users?sort=-created_at,name → ORDER BY created_at DESC, name ASC
Full-text Search
GET /users?search=john → WHERE (name LIKE '%john%' OR email LIKE '%john%')
The fields searched are defined by $searchable on the model or searchable_fields in config.
Combining Filters
All filters are combined with AND logic. Mix freely:
GET /users?status=active&age>=25&country=in(india,usa)&sort=-created_at
Relationship Filtering
SmartFilter uses dot notation to filter across Eloquent relationships. No extra configuration needed — just use the relationship method name followed by the field you want to filter on.
Basic whereHas
Filter users who have at least one published post:
GET /users?posts.status=published
All Phase 1 operators work inside relationship filters:
GET /users?posts.title~laravel → posts with title containing "laravel"
GET /users?posts.status!=draft → posts where status is not draft
GET /users?posts.status=in(published,archived)
GET /users?posts.views>=1000
Nested Relationships
Chain as many levels deep as you need:
GET /users?company.name=Acme Corp
GET /users?company.city~New
GET /users?company.address.city=London
Each dot segment is a relationship method name. The last segment is the field on the related model's table.
Many-to-Many
Works with BelongsToMany out of the box:
GET /users?roles.name=admin
GET /users?roles.name=in(admin,editor)
Existence Checks
Check whether a relationship exists or not, without filtering on a specific field:
GET /users?posts=has → users who have at least one post
GET /users?posts=doesntHave → users who have no posts
GET /users?posts=orHas → OR has at least one post
Combining with Flat Filters
Relationship filters and flat filters compose naturally with AND logic:
GET /users?status=active&posts.status=published
GET /users?country=india&roles.name=admin&sort=-created_at
Model Setup for Relationships
Define your Eloquent relationships as normal. No special configuration needed on the related model:
class User extends Model { use Filterable; protected array $filterable = ['name', 'email', 'status']; public function posts(): HasMany { return $this->hasMany(Post::class); } public function company(): BelongsTo { return $this->belongsTo(Company::class); } public function roles(): BelongsToMany { return $this->belongsToMany(Role::class); } }
Note: The parent model's
$filterablewhitelist applies only to the parent's own columns. Fields inside a relationship subquery are validated independently, soposts.titlewill work even iftitleis not in the User model's$filterable.
Configuration
After publishing (vendor:publish --tag=smart-filter-config), config/smart-filter.php gives you global control:
return [ 'allowed_fields' => [], // Global whitelist (empty = all allowed) 'ignored_fields' => ['password'], // Always blocked 'aliases' => [], // ['city' => 'address_city'] 'searchable_fields' => [], // Default ?search= fields 'sort_param' => 'sort', // Query param for sorting 'search_param' => 'search', // Query param for search 'strict' => false, // Throw on unknown fields 'operators' => [], // Custom operator classes 'debug' => false, // Log parsed filters + SQL ];
Model-Level Configuration
Model properties always take precedence over global config:
| Property | Type | Description |
|---|---|---|
$filterable |
list<string> |
Whitelisted fields. Empty = all allowed. |
$filterIgnore |
list<string> |
Always-blocked fields. |
$filterAliases |
array<string, string> |
Request param → real column name mapping. |
$searchable |
list<string> |
Fields used by ?search=. |
$filterStrict |
bool |
Throw exceptions instead of silently skipping. |
Custom Operators
Implement OperatorContract and register it in a service provider:
use Pradeepdev\SmartFilter\Facades\SmartFilter; use Pradeepdev\SmartFilter\Contracts\OperatorContract; use Pradeepdev\SmartFilter\DTOs\FilterInput; use Illuminate\Database\Eloquent\Builder; class StartsWithOperator implements OperatorContract { public function apply(Builder $builder, FilterInput $input): Builder { return $builder->where($input->field, 'LIKE', $input->value . '%'); } public function handles(): array { return ['starts_with']; } } // In AppServiceProvider::boot() SmartFilter::extend(new StartsWithOperator());
Use it in the URL:
GET /users?name=starts_with(Jo)
Or register via config:
// config/smart-filter.php 'operators' => [App\Filters\StartsWithOperator::class],
Pagination
SmartFilter is fully compatible with all Eloquent pagination methods:
User::smartFilter()->paginate(15); User::smartFilter()->simplePaginate(15); User::smartFilter()->cursorPaginate(15);
Testing Your Controllers
Pass a custom Request directly to smartFilter() to keep tests fast and HTTP-free:
$request = Request::create('/users', 'GET', [ 'status' => 'active', 'posts.status' => 'published', 'sort' => '-created_at', ]); $results = User::smartFilter($request)->get();
Architecture
src/
├── Contracts/ — FilterContract, OperatorContract, ParserContract, OperatorRegistryContract
├── DTOs/ — FilterInput, RelationFilterInput, SortInput, SearchInput (all readonly)
├── Enums/ — Operator (canonical names), SortDirection
├── Collections/ — FilterCollection (immutable typed container)
├── Parser/ — RequestParser (HTTP → FilterCollection, with dot-notation routing)
├── Operators/ — One class per operator (Equals, Like, In, Between, …)
├── Builders/ — FilterBuilder, RelationFilterApplier
├── Support/ — OperatorRegistry, FieldGuard
├── Traits/ — Filterable (adds scopeSmartFilter to any model)
├── Facades/ — SmartFilter
├── Exceptions/ — Typed, descriptive exceptions
└── SmartFilterServiceProvider.php
Data flow:
HTTP Request
↓
RequestParser ──→ FilterCollection
├── filters[] (flat WHERE conditions)
├── relationFilters[] (dot-notation whereHas chains)
├── sorts[]
└── search
↓
FieldGuard ──→ alias resolution, allow/deny on flat fields
↓
FilterBuilder
├── flat filters ──→ OperatorRegistry ──→ Builder::where(...)
├── relation filters ──→ RelationFilterApplier ──→ Builder::whereHas(...)
├── sorts ──→ Builder::orderBy(...)
└── search ──→ Builder::where(orWhere...)
↓
Eloquent Builder (fully composed, ready for ->get() / ->paginate())
Roadmap
| Phase | Status | Feature |
|---|---|---|
| 1 | ✅ Complete | Core operators, sorting, search |
| 2 | ✅ Complete | Relationship filtering (whereHas, has, nested) |
| 3 | 🔄 Planned | Date filters (today, last_week, this_month…) |
| 4 | 🔄 Planned | JSON column filtering |
| 5 | ✅ Complete | Custom operator registration |
| 6 | ✅ Complete | Full config publishing |
| 7 | 🔄 Planned | Performance optimisation & caching |
| 8 | 🔄 Planned | Macros, IDE helpers, PHPStan types |
| 9 | ✅ Partial | Pest test suite (105 tests, 210 assertions) |
| 10 | ✅ Complete | README & documentation |
FAQ
Q: Does this prevent SQL injection?
A: Yes. All filter values are passed via PDO bound parameters through Eloquent. Field names are validated against an allow-list before interpolation. Input is additionally sanitised at parse time (null bytes and control characters stripped).
Q: Can I use this without the trait?
A: Yes. Resolve FilterBuilder from the container and call apply($builder, $collection) directly.
Q: What happens with unknown filter params?
A: By default they are silently skipped. Set $filterStrict = true on the model (or 'strict' => true in config) to throw InvalidFilterFieldException instead.
Q: Does the parent model's $filterable block relationship fields?
A: No. $filterable on the parent model only applies to the parent's own columns. Fields inside a relationship subquery (posts.title, company.city) use a permissive guard so they're never accidentally blocked by the parent's whitelist.
Q: Which relationship types are supported?
A: HasMany, BelongsTo, BelongsToMany, and HasOne. Any Eloquent relationship that supports whereHas works.
Q: Can I nest relationships more than one level?
A: Yes, unlimited depth. ?company.address.city=London works, as does ?org.department.team.lead_name=Alice.
Contributing
See CONTRIBUTING.md.
Security
See SECURITY.md. Please do not open public issues for vulnerabilities.
Changelog
See CHANGELOG.md.
License
MIT — see LICENSE.