Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Eloquent Filters Laravel Package

enimiste/laravel-eloquent-filters

Add reusable, composable query filters to Laravel Eloquent models. Define filters once, apply them from requests or code, and keep complex list and search queries clean, consistent, and maintainable.

View on GitHub
Deep Wiki
Context7

Getting Started

The package is small (24 stars, MIT, single maintainer), so treat the README and the src/ directory as the documentation. Budget ten minutes to read both before wiring anything into a controller — the surface area is tiny and you will spend less time reading than guessing at method names.

Minimal setup:

  1. composer require enimiste/laravel-eloquent-filters
  2. If the package ships a config file, publish it: php artisan vendor:publish --provider="Enimiste\LaravelEloquentFilters\..." (check composer.json → extra.laravel.providers for the exact FQCN). On Laravel 5.5+ the service provider is auto-discovered.
  3. Add the package's trait to the model you want to filter (something like use Filterable; — confirm the exact trait name in src/).
  4. Declare the filters the model understands, either as an array of filter classes or as a filters() method returning them, depending on how the package is structured.

First use case — a search/filter endpoint:

// routes/api.php
Route::get('/orders', [OrderController::class, 'index']);

// OrderController
public function index(Request $request)
{
    return Order::query()
        ->filter($request->only(['status', 'customer_id', 'created_after']))
        ->paginate(25);
}

The mental model for the whole package: the model declares which filters exist, the caller supplies key/value pairs, and the package applies only the ones it recognizes. Everything else in this assessment follows from that contract.

Start with two filters on a real model — one exact match (status) and one range or date filter — before generalizing. That exercises both the "scalar equality" and "operator" code paths and tells you how the package handles missing keys.

Implementation Patterns

One filter class per concern. The idiomatic shape is a class per filter with an apply($query, $value) method (name may differ — verify). Resist the urge to write one giant SearchFilter. Independent classes are individually testable, individually reusable across models, and make it obvious in a stack trace which filter produced a bad query.

class StatusFilter
{
    public function apply($query, $value)
    {
        return $query->where('status', $value);
    }
}

Whitelist at the model, validate at the edge. The model's filter list is your whitelist — nothing outside it should ever reach the query builder. Pair that with a FormRequest that validates types and allowed values, so a bad status=drop_table fails with a 422 instead of silently no-op'ing.

class OrderFilterRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'status'       => ['sometimes', Rule::in(['open', 'paid', 'cancelled'])],
            'customer_id'  => ['sometimes', 'integer', 'exists:customers,id'],
            'created_after'=> ['sometimes', 'date'],
        ];
    }
}

Build the filter payload deliberately. Prefer $request->only([...]) or $request->validated() over $request->all(). Passing all() means every future query-string param becomes a candidate filter, and any filter that interprets "unknown key" loosely will surprise you.

Compose with the rest of the builder. Filters are just query constraints — pagination, eager loading, and sorting all still apply and should be chained after the filter so ordering reads naturally:

Order::with('customer')
    ->filter($request->validated())
    ->sortBy($request->get('sort', '-created_at'))
    ->paginate(25);

Compose filters internally for complex cases. A "date range" filter usually wants both bounds; keep that as one filter class rather than two, so the model's filter list stays readable and callers can't send a half-open range that behaves differently depending on key ordering.

Relation filters. For filters on related models, use whereHas with a closure inside the filter class rather than joins. It keeps the base model's row count intact (important if you're paginating) and avoids duplicate rows. Accept the cost: whereHas compiles to a correlated exists subquery, which is slower than a join on large tables — measure before optimizing.

Reuse across models. Because filters are plain classes operating on a builder, a CreatedAfterFilter or SearchFilter can be dropped into multiple models' filter lists. This is the main payoff of the class-per-filter design; build a small internal App\Filters namespace rather than scattering them per-model.

Test filters directly. Instantiate the filter, pass a fresh Model::query(), assert on ->toSql() and bindings. That's a sub-millisecond test that catches the "wrong column name" and "wrong operator" bugs before they reach an endpoint test.

Gotchas and Tips

Unknown keys are the biggest footgun. Confirm empirically what the package does when you pass a key with no matching filter — silently ignore it, throw, or (worst case) pass it to a generic where. Write a test for ?totally_bogus=1 on day one. If it throws, wrap the call and return a 422; if it silently ignores, you're fine but don't rely on that behavior surviving an upgrade.

Empty strings and null are not the same as absent. A filter for ?status= arrives as '', not omitted. Most implementations either skip empty values or generate where status = '', and which one you get is version-dependent. Normalize with array_filter($params, fn($v) => $v !== null && $v !== '') before calling filter(), or handle it explicitly inside each filter.

Booleans arrive as strings. ?active=false is the string "false", which is truthy in PHP. Cast inside the filter (filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE)) rather than trusting the caller.

Never interpolate a request value into a column name. If the package or your filter supports a ?sort=column or ?filter_by=column parameter, that column must come from a whitelist array, not the request. Same for order direction — accept only asc/desc. This is the one place where a small, lightly-audited package can hand you a SQL injection if you write a dynamic filter carelessly.

Escape % and _ in LIKE searches. A SearchFilter doing where('name', 'like', "%{$value}%") lets users inject wildcards. Escape them, and decide deliberately whether leading-wildcard searches are acceptable — they can't use an index and will table-scan.

Watch the generated SQL, not the result set. ->toSql() plus ->getBindings() on a failing filter is faster than any debugger. For intermittent issues in staging, register DB::listen(fn($q) => logger($q->sql, $q->bindings)) temporarily. A filter that "returns nothing" is almost always a wrong column, wrong operator, or a type mismatch (string vs int on a Postgres bigint) rather than a package bug.

Check index coverage before shipping. Filters map onto WHERE clauses, and a filterable endpoint that scans the table will degrade quietly as data grows. Add composite indexes matching the combinations you actually expose — (status, created_at) for the common status + date range case beats two single-column indexes.

Version-pin it. With 24 stars the release cadence and semver discipline are unlikely to match first-party packages. Pin an exact version ("enimiste/laravel-eloquent-filters": "1.0.*") and read the diff before bumping. If the package goes unmaintained, the filter classes are thin enough that vendoring them into App\Filters is a realistic escape hatch — which is another argument for keeping your filters small and free of package-specific assumptions.

Extension points to look for. Before writing a workaround, grep src/ for an interface or abstract base class the filters implement, and for any container binding or config array that maps filter names to classes. Most packages in this genre expose both, and registering a custom filter through the intended hook is cheaper than subclassing a core class you don't control.

Don't let it own your controller. Keep the filter call as one line in a query-building method, not spread across the controller. When (not if) you outgrow the package, swapping that single line for hand-rolled scopes or a laravel-model-filter-style pipeline should be a 20-minute change, not a rewrite.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
se7enxweb/flysystem
lampwick/laravel
jeslage/statamic-emojis
micahdshackelford/rmap-laravel
draftsmaninc/draftsman
square-bit/laravel-volition
himito/laravel-mailman
htinlynn/genius-helper
maco-studios/hephaestus
elmogy/larafast
xeviant/laravel-paystack
gorilladash/laravel-website-sdk
martialbe/flysystem-oss
chondal/3312client
jeroen-g/flickr
opscale-co/nova-genbi
itsjustvita/laravel-retention
tequilarapido/trackit
gregsphotography/site-builder-filament
cabaoctavio/laravel-query-log