rmasters/filter
rmasters/filter is a Laravel package for filtering Eloquent models from request query strings. Define reusable filters, map them to parameters, and keep controllers clean with composable, request-driven query filtering.
Install and wire it up
composer require rmasters/filter
Then check two things before writing any code:
composer.json of the package — look at the illuminate/* version constraints. This package is small and old (21 stars, MIT, predates Laravel 5.5 auto-discovery in all likelihood). If your app is on Laravel 9/10/11 you will probably need --ignore-platform-reqs or a fork; confirm this on day one rather than debugging a confusing "trait not found" later.src/ — the trait and the service provider. The whole package is likely one trait plus a service provider. Read it end-to-end (10 minutes) so you know exactly how filter names map to methods and how the input array is resolved.Register the provider manually if you're below 5.5:
// config/app.php
'providers' => [
// ...
Rmasters\Filter\FilterServiceProvider::class,
],
First use case: an index listing driven by the query string
The canonical job for this package is turning ?name=ross&active=1&sort=-created_at into a constrained query without hand-writing if ($request->has(...)) blocks in every controller.
// app/Models/User.php
use Rmasters\Filter\Filterable;
class User extends Model
{
use Filterable;
// Explicit allow-list — see Gotchas, this matters
protected $filters = ['name', 'email', 'active', 'created_after'];
}
// app/Http/Controllers/UserController.php
public function index(Request $request)
{
return User::filter($request->only(['name', 'email', 'active', 'created_after']))
->paginate(25);
}
If that runs, you've validated the core contract. Everything else is refinement.
Pattern 1 — Controller as a thin adapter, filter logic on the model
Keep the controller dumb. It should only decide which keys are allowed and pass them through; the model owns the SQL.
public function index(Request $request)
{
$users = User::filter($request->only($this->allowedFilters()))
->with('team')
->paginate(25)
->appends($request->query());
return view('users.index', compact('users'));
}
private function allowedFilters(): array
{
return ['name', 'email', 'active', 'team_id', 'created_after'];
}
appends($request->query()) is the detail people forget — without it your pagination links drop the filters on page 2.
Pattern 2 — One method per filter, named predictably
Regardless of whether the package maps name → filterName() or scopeFilterName(), the discipline is the same: one small, testable method per concept.
public function filterName($query, $value)
{
return $query->where('name', 'like', "%{$value}%");
}
public function filterActive($query, $value)
{
return $query->where('active', (bool) $value);
}
public function filterCreatedAfter($query, $value)
{
return $query->where('created_at', '>=', Carbon::parse($value)->startOfDay());
}
Return the query builder every time. If the package's convention is to mutate $this instead, mirror that — but be consistent across the whole model.
Pattern 3 — Filters that are really "query objects"
For anything beyond where x = y (date ranges, "search across several columns", whereHas), don't cram it into a filter method. Delegate:
public function filterSearch($query, $term)
{
return $query->where(function ($q) use ($term) {
$q->where('name', 'like', "%{$term}%")
->orWhere('email', 'like', "%{$term}%");
});
}
public function filterTeam($query, $teamId)
{
return $query->whereHas('team', fn ($q) => $q->where('id', $teamId));
}
Note the closure around the orWhere — wrap OR groups or a later where will silently change the result set.
Pattern 4 — Sorting alongside filtering
Most filter packages don't do ordering. Handle it explicitly so ?sort=-created_at doesn't leak into the filter input:
$sort = $request->input('sort', '-created_at');
$direction = str_starts_with($sort, '-') ? 'desc' : 'asc';
$column = ltrim($sort, '-');
$users = User::filter($request->only($allowed))
->orderBy(in_array($column, ['name', 'created_at', 'email']) ? $column : 'created_at', $direction)
->paginate(25);
Never pass a raw user string to orderBy.
Pattern 5 — Compose with existing scopes
Filterable models rarely exist in isolation. Layer scopes for concerns that aren't user-driven:
User::active() // scope, always applied
->filter($request->only($allowed))
->paginate(25);
This keeps the filter input purely about user-supplied refinements and the scope about business rules.
Pattern 6 — Validate before you filter
Filters change the shape of a query, but they're still input. A FormRequest with sometimes|string|max:255 on each filter key costs nothing and turns silent typos into 422s your frontend can render.
class UserIndexRequest extends FormRequest
{
public function rules(): array
{
return [
'name' => ['sometimes', 'string', 'max:255'],
'active' => ['sometimes', 'boolean'],
'created_after'=> ['sometimes', 'date'],
];
}
}
Pattern 7 — Test the filters, not the controller
Because the filter lives on the model, you can assert SQL-level behaviour directly:
public function test_name_filter_is_partial_match()
{
User::factory()->create(['name' => 'Ross Masters']);
User::factory()->create(['name' => 'Jane Doe']);
$this->assertCount(1, User::filter(['name' => 'ross'])->get());
}
The allow-list is not optional. If the package iterates whatever array you hand it and calls a dynamically-named method, passing $request->all() is an invitation. Either pass $request->only([...]) or validate with a strict rule set. Prefer both.
Empty and falsy values are the #1 source of bugs. ?active=0 and ?active= and an absent key are three different things. $request->all() will give you '' for the middle case, (bool) '' is false, and your "show only inactive" filter silently becomes "show all inactive plus everything else." Decide up front whether your filters ignore empty strings:
public function filterName($query, $value)
{
return $value === '' || $value === null
? $query
: $query->where('name', 'like', "%{$value}%");
}
Or strip empties in the controller with array_filter($input, fn ($v) => $v !== '' && $v !== null). Pick one and apply it everywhere.
Array inputs (?ids[]=1&ids[]=2). where('id', $array) throws. If you accept arrays, convert with whereIn. If you don't, reject them in validation — string rules will do it.
Case sensitivity. MySQL's default collation makes LIKE '%ross%' case-insensitive; Postgres's LIKE is not. If you're on Postgres, use ilike or lower() consistently, or your tests will pass locally and fail in staging.
Version drift. This package targets an older Laravel generation. Things to verify on install:
Model internals that have since changed (e.g. newQuery(), $this->query)?Illuminate\Support\ServiceProvider methods that are deprecated?array_get/str_* helpers that were removed?If it's incompatible, the trait is almost certainly small enough to copy into app/Support/Filterable.php and maintain yourself — often a better trade than pinning your whole framework. Do this deliberately, with a note in your README, so the next developer isn't confused about why composer.json mentions a package that isn't used.
Debugging filters. When a filter produces the wrong result set, dump the SQL rather than reasoning about it:
$query = User::filter($input);
dd($query->toSql(), $query->getBindings());
Also log which filters actually fired — a two-line Log::debug inside a base filter method saves a lot of "why is this query slower with the same params" confusion.
Watch for double-application. If a filter method also appears as a scope and you call both, you'll get a redundant WHERE (usually harmless, occasionally a wrong AND group). Keep filter methods and scopes as distinct namespaces in your head.
Extension point: centralise cross-cutting filter behaviour. If the package routes all filters through one resolver method, override it. That's your hook for logging, normalising empty values, or enforcing the allow-list in one place instead of in twelve controllers:
protected function applyFilter($query, $name, $value)
{
Log::debug('filter.applied', ['model' => static::class, 'filter' => $name]);
return parent::applyFilter($query, $name, $value);
}
Don't fight it for complex queries. Date-range dashboards, multi-select facets, full-text search — these want a dedicated Query Object or a package like spatie/laravel-query-builder. rmasters/filter earns its keep for straightforward, key-value refinements on CRUD index pages. If you find yourself writing filterNotStatusOrCreatedBetween, you've outgrown it; move that endpoint to explicit query construction and leave the package for the simple listings.
Pin the version. With a low-traffic package, "rmasters/filter": "^1.0" is safer than "*", and committing composer.lock is genuinely load-bearing here.
How can I help you explore Laravel packages today?