pricecurrent/laravel-eloquent-filters
Laravel Eloquent Filters adds reusable, request-driven filters to Eloquent queries. Define filter classes, map query-string parameters, and compose clean, testable filtering logic for search pages, APIs, and admin lists without bloating controllers or models.
Install
composer require pricecurrent/laravel-eloquent-filters
No service provider or config file to publish — the package is a thin set of classes. If your app uses package discovery, you're done.
Where to look first
Read these three files before writing anything (the whole package is a few hundred lines):
src/Filterable.php — the trait added to models; exposes the filter() query scope.src/AbstractFilter.php — the contract every filter implements (isApplicable(), apply(Builder $query): Builder).src/FiltersCollection.php — the container that lets you apply many filters at once.First use case — an opt-in search filter on an index endpoint
// app/Models/User.php
use Illuminate\Database\Eloquent\Model;
use Pricecurrent\LaravelEloquentFilters\Filterable;
class User extends Model
{
use Filterable;
}
// app/Filters/User/NameFilter.php
namespace App\Filters\User;
use Illuminate\Database\Eloquent\Builder;
use Pricecurrent\LaravelEloquentFilters\AbstractFilter;
class NameFilter extends AbstractFilter
{
public function __construct(private ?string $name) {}
public function isApplicable(): bool
{
return filled($this->name);
}
public function apply(Builder $query): Builder
{
return $query->where('name', 'like', "%{$this->name}%");
}
}
// app/Http/Controllers/UserController.php
use App\Filters\User\NameFilter;
use App\Models\User;
use Illuminate\Http\Request;
public function index(Request $request)
{
$filter = new NameFilter($request->input('name'));
return User::query()
->filter($filter)
->paginate();
}
That is the entire onboarding. If you can write an Eloquent scope, you can write a filter in under five minutes.
One filter class per concern. Treat each filter as a named, single-purpose predicate: NameFilter, StatusFilter, CreatedAfterFilter, HasOrdersFilter. Don't stuff an if/elseif chain into a single filter — the whole value of the package is composability.
Gate optional params with isApplicable(). This is the most-used idiom day-to-day. Query strings arrive as empty strings/null; isApplicable() is where you decide "is this filter actually in play?" Return filled($this->value), $this->value !== null, is_array($this->ids) && count($this->ids) > 0, etc. The scope silently skips filters that report false, which keeps controllers clean.
Compose with FiltersCollection for real endpoints. For anything beyond a single filter, build the collection from the request and let the trait iterate:
use Pricecurrent\LaravelEloquentFilters\FiltersCollection;
$filters = new FiltersCollection([
new NameFilter($request->input('name')),
new StatusFilter($request->input('status')),
new CreatedAfterFilter($request->date('created_after')),
]);
$users = User::query()->filter($filters)->paginate();
Wrap that construction in a small per-resource factory class (App\Filters\User\UserIndexFilters::fromRequest($request)) so controllers stay one-liners and the mapping from query params to filters lives in one testable place. This is the pattern most teams converge on within a week.
Cross-model reusable filters. Because filters only receive a Builder, a CreatedAfterFilter or SoftDeletedFilter works across every model using the trait — good for admin tooling and reporting endpoints. Model-specific filters should live under app/Filters/{Model}/.
Compose with existing scopes and relations. Filters are just query mutations, so ->filter($filters)->with('team')->whereHas(...) chains naturally. Keep ordering and eager-loading in the controller/query object, not inside filters.
Test the filter, not the endpoint, for query-shape bugs. Assert isApplicable() returns false for empty input, and use ->toSql() / assertDatabaseCount to verify apply() produces the intended WHERE. One FilterTest per class is cheap insurance against a typo'd column in a rarely-used filter.
Integrate with FormRequests for validation. The package deliberately does not parse or validate input. Validate request params in a FormRequest, then construct filters from $request->validated() — that way a filter never receives a type it can't handle.
apply() must return the builder. It's easy to write $query->where(...) without return inside a non-arrow method. The scope will quietly apply nothing and your tests will pass because no assertion checks the SQL. Always return $query (or use arrow functions for simple filters).
Skipped filters are silent. When isApplicable() returns false there is no log line or exception — the filter just doesn't run. During debugging, temporarily log inside isApplicable() or dump the collection before ->filter() to confirm what actually fired.
No request-to-filter binding is built in. There's no allowedFilters, no ?filter[name]= parser, no nested-parameter support. You write the constructor arguments and the mapping yourself. This is fine (and arguably the point), but don't expect Spatie-style ergonomics. If your product needs a filter DSL, budget for a small adapter layer on top.
Type juggling of query strings. $request->input('status') is always a string; $request->boolean('active') or filter_var before passing into the filter. isApplicable() using filled() will correctly reject '', but "0" is considered filled — be explicit where zero is a valid value.
Order within the collection matters for SQL, not correctness. Multiple where filters compose in insertion order. Put filters that change SELECT/JOIN behavior (e.g. whereHas) before heavily-selective where clauses only if you care about the generated SQL shape; the package will not reorder for you.
Scope semantics on empty collections. ->filter(new FiltersCollection([])) is a no-op — safe to pass unconditionally rather than branching in the controller. Prefer that over if ($filters->isNotEmpty()).
Small package, small surface. 176 stars, a low score, and one maintainer means: pin the version in composer.json, read src/ when you upgrade (last release March 2025), and don't rely on a documentation site. The extension points you have are the public AbstractFilter and Filterable trait — if you override filter() in your model, watch for trait conflicts with SoftDeletes/HasScopes.
Consider the build-vs-adopt line. The whole feature is effectively one trait with a foreach and an abstract class. If your team already has a HasFilters trait, this package adds little. Its value is convention: a consistent AbstractFilter shape across the codebase, which pays off when ten people are writing filters.
Debugging tip: create a dumpSql($filter) helper in your base test case that builds the query with a single filter and asserts against ->toSql(). It catches the missing-return and wrong-column classes of bugs faster than full feature tests.
How can I help you explore Laravel packages today?