lacodix/laravel-model-filter
A Laravel package for filtering, searching, and sorting Eloquent models using query strings. It includes built-in filter types for strings, dates, numbers, and enums, plus custom filters for complex database logic.
Install and scaffold. The package is a pure-PHP Eloquent extension — no service provider config is required to get value on day one:
composer require lacodix/laravel-model-filter
php artisan make:filter CreatedAfterFilter --type=date --field=created_at
The generator drops a class into app/Models/Filters/ that declares a type and field. Open that file first: it is the whole mental model of the package. Everything else (query strings, blade widgets, grouping, preparation) is machinery around these small, testable filter classes.
Wire one model. Add the HasFilters trait and a whitelist array:
// app/Models/Post.php
use Lacodix\LaravelModelFilter\Traits\HasFilters;
class Post extends Model
{
use HasFilters;
protected array $filters = [
CreatedAfterFilter::class,
];
}
First real use case: a filterable index page. Rather than hand-rolling $request->whenHas(...) blocks in a controller, you call a scope:
// GET /posts?created_after_filter=2023-01-01
$posts = Post::query()
->filterByQueryString()
->latest()
->paginate(25);
That single call replaces the most common boilerplate in admin panels and list endpoints. The filter key (created_after_filter) is derived from the class name, so the class name is your public API — decide on naming before you ship URLs.
Add search in the same pass. IsSearchable + a $searchable array gives you ?search=term across columns, including nested relations:
use Lacodix\LaravelModelFilter\Traits\IsSearchable;
protected array $searchable = ['title', 'content', 'author.name'];
Post::query()->searchByQueryString()->filterByQueryString()->get();
Then reach for the UI. If you are building a Blade admin, <x-lacodix-filter::model-filters :model="Post::class" /> renders inputs for every registered filter. Treat it as a scaffold to publish and restyle, not as a finished design system.
Whitelist-in-the-model, transport-in-the-controller. Keep the $filters array on the model as the security boundary, and keep the controller thin. The scope only ever runs filters that appear in that array, so unknown query parameters are inert rather than dangerous:
public function index(Request $request)
{
return Post::query()
->filterByQueryString()
->searchByQueryString()
->paginate($request->integer('per_page', 25));
}
For anything beyond a prototype, put a FormRequest in front of it so bad values fail loudly with a 422 instead of silently returning an empty set or throwing deep in a filter:
$validated = $request->validate([
'created_after_filter' => ['nullable', 'date'],
'status_filter' => ['nullable', Rule::enum(PostStatus::class)],
]);
Group filters by surface, not by type. This is the highest-leverage feature for real apps. Frontend and backend rarely share a filter set, and groups let one model serve both without conditional logic in controllers:
protected array $filters = [
'frontend' => [HotFilter::class],
'backend' => [CreatedAfterFilter::class, PublishedFilter::class],
];
Post::filterByQueryString('frontend')->get();
Post::filter(['created_after_filter' => now()->subWeek()], 'backend')->get();
Check the config for the group-fallback option (added in v3.7.0) — without it, requesting a group that does not exist is an error, which is easy to hit when a group is renamed.
Filters on relations: use RunsOnRelation. Instead of writing manual whereHas closures with qualified column names, apply the trait and the package wraps your filter logic in whereHas and prefixes the field with the related table. This is where custom filters usually stop being one-liners and start being bug farms, so let the trait own it.
Custom filters for domain logic. For anything the built-in string/date/number/enum/boolean/select types cannot express, subclass the base filter and implement the query mutation. Keep the logic in the filter and not in the scope — that is what makes it unit-testable:
$query->whereHas('subscription', fn ($q) => $q->where('ends_at', '>=', now()));
Filters also expose a static make(), so programmatic composition reads cleanly: Post::filter([...]) built from a saved search, a dashboard widget, or an API payload.
Metadata endpoint for SPA/API clients. For a Vue/React frontend you need to know which filters exist and what values are legal — but you do not want to run a query to find out. FilterPreparation resolves fresh filter instances, applies the same selection/population/normalisation/validation as filter(), and returns only the filters that are ready to apply, without touching the database:
use Lacodix\LaravelModelFilter\Support\FilterPreparation;
$prepared = (new FilterPreparation)->prepare(
model: new Post,
values: $request->all(),
group: 'backend',
strictGroup: true,
);
Use this to build a /filters endpoint that returns filter keys, types, and allowed enum values, or to pre-validate a saved-filter payload before persisting it.
Saved searches and presets. v4.6.0 added filter presets for the UI layer. Combined with filter([...]) taking a plain array, this gives you user-saved filter sets with very little code: serialise the array, store it, replay it through the scope.
Testing. Assert on the query string, not on the filter internals. A feature test hitting ?created_after_filter=2023-01-01 and asserting the returned IDs covers the binding, the population, and the SQL in one shot. The package ships testing helpers (v3.8.0, SQLite fixes in v4.4.2) — check whether they already cover your assertion before writing a raw assertDatabaseCount.
The class name is the wire format. CreatedAfterFilter becomes created_after_filter in the query string. Refactor-renaming the class silently breaks bookmarked URLs, frontend form field names, and any stored saved-search payloads. If a filter is user-facing, treat the class name as versioned API.
Sorting and filtering share a trap: unbounded input. filterByQueryString() reads the query string, so make sure your $filters whitelist is complete and intentional. Never append filters to the array dynamically from request data — that reintroduces the mass-assignment problem the package exists to avoid.
search() is a leading-wildcard LIKE. It cannot use a B-tree index, and on a table past a few hundred thousand rows it will be your slowest query. Two mitigations: use searchLiteral() when user input may contain %, _, or driver-specific wildcards (added v4.7.0) so a literal 100%_complete does not turn into a wildcard scan; and move to a full-text index or Laravel Scout for genuinely large corpora rather than tuning the filter.
Relation filters are whereHas, which is EXISTS. It is correct and it is not free. Add indexes on the foreign key and the filtered column, and watch for N+1 in the opposite direction — if the filtered relation is also rendered, eager load it explicitly. v3.5.2 fixed eager-loading on nested search with morphs, so if you are on an older version and see duplicate queries on a morph relation, upgrade first.
Date filters vary by driver. MySQL and SQLite handle date truncation and comparison differently; v3.3.2 and v4.4.2 exist because of exactly this. Store UTC, convert at the edges, and run your test suite against the same driver you deploy on — a green SQLite suite is not proof your MySQL date filter is right.
Empty and null are different things. A missing query parameter means "do not filter". A 0, false, or empty string can mean "filter for the falsy value". The nullable option on select filters (v4.5.0) and the boolean filter (v4.0.1) exist precisely to express IS NULL and = false. Test all three states — absent, null, falsy — for every enum, boolean, and select filter, because this is the single most common source of "the filter does nothing" bug reports.
Version pinning is not optional here. v4.1.0 added Laravel 13 / PHP 8.5 support and dropped Laravel 10 and PHP 8.1; v4.2.0 dropped L10 support entirely. If you are on Laravel 10, stay on the 3.x line — and read docs/upgrade.md before jumping majors, since the FilterMode enum and filter base classes have churned. Given the fast release cadence (multiple releases per minor feature), pin a specific minor and upgrade deliberately.
The blade component is a starting point. <x-lacodix-filter::model-filters> gives you functional markup, not styling that matches your app. Publish the views, wrap them in your own form component, and own the layout. Also note enum filters can choose whether the enum value or key is used for the displayed translation — get this right early, or every enum filter will need reworking when you add i18n.
Filters are resolved per query. Do not expect constructor dependency injection into filter classes to behave like a singleton; anything stateful you cache inside a filter instance is per-resolution. If a filter needs config, read it from config() or a static source at apply time, not from constructor-injected state.
The generator is the extension point. php artisan make:filter --type=... --field=... will hand you the right base class for the built-in types. When you need custom behaviour, generate the closest built-in type first and then modify apply() — it saves you from guessing which abstract method signature the current version expects.
How can I help you explore Laravel packages today?