nahidulhasan/eloquent-filter
Eloquent Filter brings declarative, request-driven filtering to Laravel Eloquent models. Define allowed filters, map query-string parameters to columns or scopes, and keep controllers clean while composing reusable, chainable filter rules for APIs and admin listings.
Minimal steps
composer require nahidulhasan/eloquent-filter
src/ and note three things: the trait name, the scope/entry method it registers, and how it resolves filter classes (namespace convention vs. config vs. registration). Those three facts drive everything below; confirm the exact spelling there before writing code, since small packages rename things between minor versions.php artisan vendor:publish --provider="NahidulHasan\EloquentFilter\ServiceProvider"
use NahidulHasan\EloquentFilter\Traits\Filterable;
class Post extends Model
{
use Filterable;
}
$posts = Post::query()->filter($request->only(['title', 'status']))->paginate(20);
// ->toSql() to confirm the WHERE clauses landed
First use case
The index endpoint of an admin or listing page — a handful of exact-match / partial-match filters driven by query string (?status=published&title=laravel). That is the sweet spot this package was built for. Do not start with relation filtering or complex boolean logic; walk before you run.
Where to look first when something breaks
src/Traits/ for the scope and parameter handling, src/Filters/ (or whatever the base class lives in) for the extension contract, and the package's tests/ if any exist. If there are no tests, treat every behaviour as unverified and write your own integration test immediately.
One filter class per model, one method per filterable field
Keep the filter class a thin translation layer from request keys to query constraints. No business logic, no authorization, no HTTP concerns.
class PostFilter extends Filter
{
public function title($value)
{
return $this->builder->where('title', 'like', "%{$value}%");
}
public function status($value)
{
return $this->builder->where('status', $value);
}
public function author($value)
{
return $this->builder->whereHas('author', fn ($q) => $q->where('name', 'like', "%{$value}%"));
}
}
Whitelist at the edge, not in the filter
The filter class is not a security boundary — a caller passing ?password=... should never reach it. Validate the allowed keys in a FormRequest and pass only those through:
$filters = $request->validated(); // only declared keys survive
$posts = Post::query()->filter($filters)->paginate();
This is the single most important pattern. It makes the filter class safe to extend without re-auditing every call site.
Ignore empty values centrally
Query strings arrive as ?status= far more often than you'd like. Either strip blanks before calling the filter, or guard inside each method (if ($value === null || $value === '') return $this->builder;). Pick one place and be consistent — mixed handling produces the classic "filtering by empty string returns nothing" bug report.
Compose with pagination, sorting, and eager loading, not against them
$posts = Post::query()
->with(['author:id,name'])
->filter($request->validated())
->orderBy($request->input('sort', 'created_at'), $request->input('dir', 'desc'))
->paginate(20)
->withQueryString();
withQueryString() is non-negotiable on filtered listings — otherwise page 2 drops the filters and users see inconsistent results.
Reusable filters across models
If two models both need a date-range filter, extract the body into a small trait or a helper ($this->whereDateRange('created_at', $value)). Small packages like this rarely ship that ergonomics layer; adding it costs 20 lines and removes a lot of copy-paste.
Testing pattern
Test the filter class directly against a real (SQLite in-memory) database rather than mocking the builder. Assert on returned IDs — not on SQL strings — so the tests survive a package upgrade:
$results = Post::query()->filter(['status' => 'published'])->pluck('id');
$this->assertEquals([$published->id], $results->all());
Add one test per filter method, plus one test that an unknown key is ignored rather than throwing.
Treat the package as unverified code. 24 stars and a low score means you are an early adopter. Before building on it, read src/ end-to-end (it is likely a few hundred lines), and pin the version in composer.json with a ~ constraint. If the package turns out to be under 150 lines of real logic, seriously consider vendoring it into app/Support/ and owning it — you will spend less time than tracking upstream.
Unknown keys: know whether they throw or are silently ignored. Both behaviours are common and both are dangerous in different ways. Silently ignored keys hide typos in your frontend (?statuss=published looks like it works); exceptions leak internal filter names to users. Test it explicitly and decide which you want, then enforce it in the FormRequest.
like wildcards are user input. % and _ in a search term are wildcards in SQL. If users can type them, escape them: addcslashes($value, '%_\\'). Also remember MySQL's default collation makes like case-insensitive while Postgres's like is case-sensitive — a filter that "works locally" can behave differently in production.
Boolean and numeric casting. ?active=false is the string "false", which is truthy in PHP but a valid 0 to MySQL — so it can accidentally match nothing or everything. Cast explicitly in the filter method (filter_var($value, FILTER_VALIDATE_BOOLEAN)) or validate the type in the FormRequest with boolean / integer rules.
Relation filters cost queries and can break pagination. whereHas compiles to a correlated subquery; several of them stacked on a large table with paginate() produces a slow count(*) and then a slow page query. Watch DB::listen on the first realistic dataset, add indexes on the foreign keys used, and consider whereIn with a pre-fetched ID list if the relation filter is heavily used.
Scope ordering matters. filter() is just a scope; calling it after orderBy versus before is usually irrelevant, but calling it after a limit/union/groupBy can produce surprising SQL. Keep it near the top of the chain, right after query().
Debugging: dump the SQL, not the builder. ->toSql() plus ->getBindings() tells you instantly whether a filter was applied. Bind a temporary DB::listen(fn ($q) => logger($q->sql, $q->bindings)) in a service provider if you need the full picture across a request.
Conflict with your own global scopes and soft deletes. A filter that does where('status', ...) is fine, but one that does orWhere will silently break a global scope's constraint. Always wrap OR-groups in a closure: ->where(fn ($q) => $q->where(...)->orWhere(...)).
Extension point: the base filter class. Whatever hook the package gives you (a before/after callback, a default method, or an overridable apply()), use it rather than editing package files — anything in vendor/ is lost on the next composer update. If the package has no clear extension point, that is a strong signal to wrap it: keep your own App\Filtering\Filter base class that delegates to it, so swapping the package later is a one-file change.
Document the filter keys for your frontend. A filtered endpoint is only useful if the query parameters are known. Put the allowed keys in the FormRequest rules — that file becomes both the validation source and the de facto API documentation, which is exactly the kind of small win that pays off daily.
How can I help you explore Laravel packages today?