caue-santos / laravel-request-filters
A laravel request filters
Package info
github.com/ca-santos/laravel-request-filters
pkg:composer/caue-santos/laravel-request-filters
Requires
- php: ^8.2
- caue-santos/auto-class-discovery: ^1.0
- laravel/framework: ^10.10|^11.0
Requires (Dev)
- orchestra/testbench: ^8.0|^9.0
- phpunit/phpunit: ^10.1|^11.0
Suggests
- doctrine/dbal: Only needed for column metadata on Laravel <11, which lacks the native Schema::getColumns().
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-01 16:34:02 UTC
README
Turn an HTTP request's query string into safe, whitelisted Eloquent query
constraints — flat filters, arbitrarily nested AND/OR filter trees, relation
traversal (including nested relations), computed fields, relation counters,
column projection, withCount() annotation, and relation-aware sorting —
without writing a single line of ad-hoc query-building code per endpoint.
Every value is applied through query bindings or structured Query Builder
calls (where, whereIn, whereBetween, whereHas, ...); the only raw SQL
fragments ever built are assembled exclusively from whitelisted column
identifiers, never from request values.
Requirements
- PHP ^8.2
- Laravel ^10.10 or ^11.0
Installation
composer require caue-santos/laravel-request-filters
The service provider is auto-discovered. To publish the config file:
php artisan vendor:publish --provider="CaueSantos\LaravelRequestFilters\RequestFiltersServiceProvider"
// config/laravel-request-filters.php return [ // Where the /filters/metadata endpoint looks for filterable models. 'models_folder' => app_path('Models'), ];
Core concepts
| Concept | What it is |
|---|---|
ModelCriteria |
The 4-method whitelist contract every filterable model exposes: filterable(), orderable(), selectable(), relatable(). |
ExtendedModelCriteria |
Optional additive contract: computedFields(), counters(), customFilters(), customSorts(), aliases(). |
SearchableModelCriteria |
Optional additive contract on top of that one: searchable(), for the q full-text search parameter. |
CriteriaBuilder |
Fluent, ready-to-use implementation of every contract above — you'll use this in almost every model. |
DefaultCriteria |
The "no restrictions" criteria (['*'] everywhere) — used when a model doesn't define its own. |
RequestFilterTrait |
Add to a model to get Model::applyCriteria(), Model::sort() and Model::getFilterDefs(). |
ApplyCriteria |
The pipeline orchestrator — inspects the current request for complexFilters, filters, q, select, count, order and applies whichever are present, in that order. |
Setting up a model
use CaueSantos\LaravelRequestFilters\Criteria\CriteriaBuilder; use CaueSantos\LaravelRequestFilters\Criteria\ModelCriteriaContract; use CaueSantos\LaravelRequestFilters\Criteria\RequestFilterTrait; use CaueSantos\LaravelRequestFilters\Support\ColumnResolver; use Illuminate\Database\Eloquent\Model; class User extends Model { use RequestFilterTrait; public function company() { return $this->belongsTo(Company::class); } public function posts() { return $this->hasMany(Post::class); } public static function criteria(): string|ModelCriteriaContract { return CriteriaBuilder::make() ->setFilterable(['first_name', 'last_name', 'email', 'status', 'age', 'company.name', 'posts.title']) ->setOrderable(['first_name', 'last_name', 'age', 'created_at', 'company.name']) ->setSelectable(['*']) ->setRelatable(['company', 'posts', 'posts.tags']) // Fields the `q` full-text search parameter may match against. ->setSearchable(['first_name', 'last_name', 'email', 'company.name']) // A field whose value is a SQL expression, not a real column. ->computed('full_name', fn ($query) => ColumnResolver::concat($query, ['first_name', 'last_name'])) // A relation-count field, optionally constrained. ->counter('posts_count', 'posts') ->counter('published_posts_count', 'posts', fn ($q) => $q->whereNotNull('published_at')) // Take over filtering for a field entirely. ->filterUsing('is_adult', function ($query, string $operator, mixed $value) { $value ? $query->where('age', '>=', 18) : $query->where('age', '<', 18); }) // Take over sorting for a field entirely. ->sortUsing('name_reversed', function ($query, string $direction) { $query->orderByRaw("last_name {$direction}, first_name {$direction}"); }) // Let `email_address` be requested in place of the real `email` column. ->alias('email_address', 'email'); } }
setFilterable(['*']) (the default) allows every field. To allow everything
except a few fields, whitelist * and exclude with a ! prefix:
->setFilterable(['*', '!password', '!remember_token'])
Then, in a controller:
public function index() { return User::applyCriteria(User::criteria())->paginate(); }
applyCriteria() reads the current request (request()->query()) — there's
nothing else to wire up per endpoint.
Query parameters reference
| Parameter | Purpose |
|---|---|
filters[field:operator:modifier]=value |
Flat filters, implicitly AND-ed together. |
complexFilters |
Arbitrarily nested AND/OR filter tree. |
q |
Full-text search across the criteria's searchable() fields. |
select |
Column projection, including relation columns. |
count |
withCount() one or more relations. |
order[asc] / order[desc] |
Comma-separated columns/relation paths to sort by. |
All six can be combined in the same request; they're applied in the order
above (complexFilters → filters → q → select → count → order).
Simple filters — filters
GET /users?filters[status:eq]=active&filters[age:gte]=18
Key shape: field[:operator[:modifier]]. Omitting the operator defaults to eq.
A comma-separated value becomes an array (used by in/between):
GET /users?filters[status:in]=active,pending&filters[age:between]=18,65
A field not present in filterable() is silently dropped — the rest of
the request still runs.
Complex nested filters — complexFilters
{
"logic": "and",
"filters": [
{ "column": "status", "operator": "eq", "value": "active" },
{
"logic": "or",
"filters": [
{ "column": "company.name", "operator": "eq", "value": "Acme" },
{ "column": "published_posts_count", "operator": "gte", "value": "5" }
]
},
{ "column": "age", "operator": "between", "value": "18,65" }
]
}
As a query string (PHP's standard bracket-array encoding — this is exactly
what URLSearchParams/qs-style nested serialization produces, and what
$request->query() parses back into the tree above):
GET /users?complexFilters[logic]=and
&complexFilters[filters][0][column]=status
&complexFilters[filters][0][operator]=eq
&complexFilters[filters][0][value]=active
&complexFilters[filters][1][logic]=or
&complexFilters[filters][1][filters][0][column]=company.name
&complexFilters[filters][1][filters][0][operator]=eq
&complexFilters[filters][1][filters][0][value]=Acme
&complexFilters[filters][1][filters][1][column]=published_posts_count
&complexFilters[filters][1][filters][1][operator]=gte
&complexFilters[filters][1][filters][1][value]=5
&complexFilters[filters][2][column]=age
&complexFilters[filters][2][operator]=between
&complexFilters[filters][2][value]=18,65
Groups nest to any depth; each group's logic (and/or) only affects its
own children. A leaf's column may be a comma-separated list paired with the
"modifier": "concat" key, to compare a concatenation of several columns
(e.g. "column": "company.name,company.city" with "modifier": "concat") —
supported when every column is local, or every column belongs to the same
relation path.
Full-text search — q
GET /users?q=wonder
Matches any of the criteria's searchable() fields (an OR-ed contains
across all of them) — plain columns, computed fields, and dotted relation
paths are all supported, resolved the same way a filters[field:contains]
condition on that same field already would be:
CriteriaBuilder::make() ->setSearchable(['first_name', 'last_name', 'email', 'company.name']);
searchable() defaults to an empty list — a criteria class must opt fields
into it explicitly; q has no effect at all on a criteria that doesn't
implement it (e.g. DefaultCriteria) or declares nothing searchable. An
empty/whitespace-only q is also a no-op.
Column projection — select
GET /users?select=id,first_name,company.name,posts.title
A relation column is loaded through a constrained eager-load — Eloquent still
hydrates $user->company and $user->posts, just with only the requested
columns (plus whatever key(s) Eloquent needs to match parents back to
children, added automatically).
Relation counts — count
GET /users?count=posts,company
Adds posts_count and company_count to every result via withCount().
Each relation is checked against relatable().
Sorting — order
GET /users?order[asc]=first_name,last_name&order[desc]=age
Supports plain columns, computed fields, custom sorts (sortUsing), and
relation paths of any depth (order[asc]=company.name,
order[asc]=company.posts.title) — the engine adds whatever LEFT JOINs are
needed, aliased and GROUP BY-ed so a to-many relation doesn't duplicate
rows.
Model::sort() (relation-aware "smart" sort) never throws on a disallowed or
unresolvable column — it's dropped instead — and falls back to a sensible
default (the primary key, or created_at for a non-integer key) when nothing
was requested. The plain OrderByCriteria::apply() used internally by
applyCriteria() throws InvalidArgumentException for a column outside
orderable().
Operators
| Operator | Meaning | Negated variant |
|---|---|---|
eq |
= |
!eq → != |
lt / lte / gt / gte |
< / <= / > / >= |
— |
contains |
LIKE %value% |
!contains |
starts |
LIKE value% |
!starts |
ends |
LIKE %value |
!ends |
empty |
IS NULL OR = '' (or, on a relation field, "has no related row") |
!empty |
in |
IN (...) |
!in |
between |
BETWEEN a AND b |
!between |
date_<shortcut> |
one of the date shortcuts below | — |
LIKE wildcards (%, _) inside a user-supplied value are always escaped —
searching for a literal % or _ matches literally, it doesn't become a
wildcard.
Value casting
A filter value is cast according to the column it's actually being compared
against, not just the value's own shape: filters[status:eq]=42 against a
varchar column compares the literal string "42", while the same value
against an integer column compares the number 42 — a numeric-looking
value (a status code, a zero-padded reference) in a text column is never
silently coerced into a number just because it looks like one. This applies
to plain columns and single-relation columns (company.some_int_column);
computed fields, counters, and custom filters are unaffected (their value is
whatever the generic true/false/number-or-string heuristic already produces,
same as always). When the real column type can't be
determined (an unresolvable column, an unsupported database driver) or isn't
one this recognises (dates are intentionally left alone — see date_<shortcut>
above), that same generic heuristic is used as a fallback.
Date shortcuts
Available as the date_<key> filter operator (filters[published_at:date_last_n_months]=3)
and as a local scope on any model using the FilterableByDates trait
(Post::whereDateIsLastNMonths('published_at', 3)):
today, yesterday, tomorrow,
this_week / last_week / next_week / last_n_weeks / next_n_weeks / n_weeks_ago,
this_month / last_month / next_month / last_n_months / next_n_months / n_months_ago,
last_n_days / next_n_days / n_days_ago,
this_quarter / last_quarter / next_quarter / last_n_quarters / next_n_quarters / n_quarters_ago,
this_year / last_year / next_year / last_n_years / next_n_years / n_years_ago,
this_financial_year / last_financial_year / next_financial_year.
Shortcuts prefixed last_n_/next_n_/n_..._ago take the filter value as
their n (filters[created_at:date_last_n_days]=7). Every range is
half-open (to excluded) except the three rolling-window day shortcuts
(last_n_days, next_n_days, n_days_ago), which are closed ranges anchored
to now.
Fiscal-year shortcuts resolve via the Contracts\FiscalYearResolver
interface, bound by default to a resolver assuming a UK-style April–March
year; register your own binding in your app's service provider to change it.
Relations, computed fields, and counters
- Relation filters:
filters[company.name:eq]=Acme,filters[posts.title:contains]=hello, arbitrarily nested (filters[users.posts.title:contains]=helloon aCompany). An unresolvable relation path is silently dropped, not an error. - Relation existence:
filters[posts.title:!empty]=1("has at least one post"),filters[posts.title:empty]=1("has no posts"). - Computed fields (
->computed(...)) are never compared/ordered by their bare alias (SQL doesn't allow referencing a SELECT alias inWHERE) — the engine always substitutes the resolved expression instead, sofilters[full_name:contains]=John Smithandorder[asc]=full_nameboth work. - Relation counters (
->counter(...)) are rewritten rather than compared/ ordered by their bare alias directly: as a filter, into a correlatedhas($relation, $operator, $count)existence check —filters[posts_count:gte]=5,filters[posts_count:between]=1,10both work; as a sort (order[asc]=posts_count), the engine adds the matchingwithCount()subselect itself (reusing one already added bycount=<relation>or an earlier sort under the same alias, rather than duplicating it) before ordering by it.
Aliases and whitelists
->alias('email_address', 'email')letsfilters[email_address:eq]=.../order[asc]=email_addressbe requested in place of the realemailcolumn — useful for renaming a field for API consumers without touching the schema.- Every whitelist (
filterable,orderable,selectable,relatable) follows the same rule:['*']allows everything; otherwise a field must be explicitly listed; either way, a'!field'entry always excludes it.
Metadata endpoint
The package registers two routes (prefixed /filters):
GET /filters/metadata # every model using RequestFilterTrait, discovered under models_folder
GET /filters/metadata/{table} # one model, looked up by table name
Each entry is the same shape Model::getFilterDefs() returns:
[
'model' => User::class,
'table' => 'users',
'fillable' => [...],
'attributes' => [...],
'columns' => [...], // real DB columns, via Schema::getColumns()
'allowed' => [
'filterable' => [...],
'orderable' => [...],
'selectable' => [...],
'relatable' => [...],
],
'relations' => [...], // relation name => related model class
]
Authorizing access to it
These routes describe a model's real columns, relations, and attributes -
not something every application wants exposed to any caller. By default
they're only reachable when app()->environment('local', 'testing') is
true; anywhere else they respond 403. To allow (or further restrict) them,
register your own check from a service provider's boot():
use CaueSantos\LaravelRequestFilters\RequestFiltersServiceProvider; RequestFiltersServiceProvider::auth(function ($request) { return $request->user()?->isAdmin() ?? false; });
To layer additional middleware (auth guards, rate limiting, ...) on top of that check instead of replacing it, list middleware classes (not closures) in the config:
// config/laravel-request-filters.php 'metadata_middleware' => ['auth:sanctum'],
Complex query examples
These are exact, verified requests against the Company (hasMany) → User (hasMany) → Post (belongsToMany) → Tag domain used in this package's own
test suite.
1. Nested AND/OR combining a relation, a counter, and a range
"Active users whose company is Acme or who have at least 5 published posts, and who are between 18 and 65 years old."
{
"logic": "and",
"filters": [
{ "column": "status", "operator": "eq", "value": "active" },
{
"logic": "or",
"filters": [
{ "column": "company.name", "operator": "eq", "value": "Acme" },
{ "column": "published_posts_count", "operator": "gte", "value": "5" }
]
},
{ "column": "age", "operator": "between", "value": "18,65" }
]
}
Generated SQL (MySQL-flavoured; the engine also runs unmodified on SQLite/PostgreSQL):
select * from `users` where ( `users`.`status` = ? and ( exists (select * from `companies` where `users`.`company_id` = `companies`.`id` and `companies`.`name` = ?) or (select count(*) from `posts` where `users`.`id` = `posts`.`user_id` and `published_at` is not null) >= ? ) and (`users`.`age` between ? and ?) )
2. Combine complexFilters and flat filters in the same request, plus select, count and relation sort
GET /users
?complexFilters[logic]=and
&complexFilters[filters][0][column]=status
&complexFilters[filters][0][operator]=eq
&complexFilters[filters][0][value]=active
&complexFilters[filters][1][logic]=or
&complexFilters[filters][1][filters][0][column]=company.name
&complexFilters[filters][1][filters][0][operator]=eq
&complexFilters[filters][1][filters][0][value]=Acme
&complexFilters[filters][1][filters][1][column]=published_posts_count
&complexFilters[filters][1][filters][1][operator]=gte
&complexFilters[filters][1][filters][1][value]=5
&complexFilters[filters][2][column]=age
&complexFilters[filters][2][operator]=between
&complexFilters[filters][2][value]=18,65
&filters[full_name:contains]=Alice
&select=id,first_name,company.name
&count=posts
&order[desc]=posts_count
Both complexFilters and filters are applied (AND-ed together) in the same
query — the flat filters[full_name:contains] narrows the result of the
nested tree above even further, select projects only the requested columns
(still eager-loading company for the requested company.name), and
count=posts adds a real posts_count column that order[desc]=posts_count
then sorts by directly (the engine reuses the same withCount() subselect
count= already added, rather than adding a duplicate one).
3. Relation counter between combined with a negated in
"Users with 1 to 10 posts, whose status is neither
bannednordeleted."
GET /users?complexFilters[logic]=and
&complexFilters[filters][0][column]=posts_count
&complexFilters[filters][0][operator]=between
&complexFilters[filters][0][value]=1,10
&complexFilters[filters][1][column]=status
&complexFilters[filters][1][operator]=!in
&complexFilters[filters][1][value]=banned,deleted
4. Date shortcut with an n argument, scoped to a relation's own table
"Posts published in the last 7 days."
GET /posts?filters[published_at:date_last_n_days]=7
Equivalent, expressed as a local scope instead of a request:
Post::whereDateIsLastNDays('published_at', 7)->get();
5. Deeply nested relation filter, sort, and select (3 levels)
"Companies that have a user with a post whose title contains a phrase" — filtered, sorted, and selected all the way down to the third relation hop.
GET /companies
?filters[users.posts.title:contains]=quarterly report
&order[asc]=users.posts.title
&select=name,users.posts.title
6. Full-text search combined with filters
"Active adult users whose name, email, or company name contains 'ali'."
GET /users
?q=ali
&filters[status:eq]=active
&complexFilters[logic]=and
&complexFilters[filters][0][column]=age
&complexFilters[filters][0][operator]=gte
&complexFilters[filters][0][value]=18
q is AND-ed with both filters and complexFilters — it narrows the
result down further rather than replacing them, exactly like every other
stage in the pipeline.
Showcase: everything at once
The three requests below exist to answer one question: what does this
package actually save you from hand-writing? Each one is a single HTTP
request a frontend could fire as-is — no controller code, no query-builder
gymnastics — and each is exact and verified: pulled straight out of this
package's own test suite (not hand-typed prose), where it's asserted against
real result sets on the Company → User → Post → Tag domain used throughout
this README.
The kitchen sink
"Users who are not banned or deleted, whose name contains 'Ali' and whose email is an
@acme-corp.testaddress, and who either (a) work at Acme Corp with at least 2 published posts, or (b) are adults with a post mentioning 'quarterly' — and, either way, are between 18 and 65 — ordered by post count then company name, with post counts annotated and only a few columns (plus company name) selected."
Every clause in that sentence is a real, independent piece of this request —
a 3-level-deep AND/OR/AND logic tree (mixing a relation condition, a
constrained relation counter, and a custom filter), two flat filters (one
of them a computed field, the other an aliased column), a negated in, a
between, column projection through a relation, a withCount() annotation,
and a two-column sort (a real relation join and a counter) — all in one
request, all still safe from SQL injection, all still respecting every
whitelist:
{
"logic": "and",
"filters": [
{ "column": "status", "operator": "!in", "value": "banned,deleted" },
{
"logic": "or",
"filters": [
{
"logic": "and",
"filters": [
{ "column": "company.name", "operator": "eq", "value": "Acme Corp" },
{ "column": "published_posts_count", "operator": "gte", "value": "2" }
]
},
{
"logic": "and",
"filters": [
{ "column": "is_adult", "operator": "eq", "value": "1" },
{ "column": "posts.title", "operator": "contains", "value": "quarterly" }
]
}
]
},
{ "column": "age", "operator": "between", "value": "18,65" }
]
}
As a query string — the tree above, plus the two flat filters, select,
count, and the two-column order, all in one request:
GET /users
?complexFilters[logic]=and
&complexFilters[filters][0][column]=status
&complexFilters[filters][0][operator]=!in
&complexFilters[filters][0][value]=banned,deleted
&complexFilters[filters][1][logic]=or
&complexFilters[filters][1][filters][0][logic]=and
&complexFilters[filters][1][filters][0][filters][0][column]=company.name
&complexFilters[filters][1][filters][0][filters][0][operator]=eq
&complexFilters[filters][1][filters][0][filters][0][value]=Acme Corp
&complexFilters[filters][1][filters][0][filters][1][column]=published_posts_count
&complexFilters[filters][1][filters][0][filters][1][operator]=gte
&complexFilters[filters][1][filters][0][filters][1][value]=2
&complexFilters[filters][1][filters][1][logic]=and
&complexFilters[filters][1][filters][1][filters][0][column]=is_adult
&complexFilters[filters][1][filters][1][filters][0][operator]=eq
&complexFilters[filters][1][filters][1][filters][0][value]=1
&complexFilters[filters][1][filters][1][filters][1][column]=posts.title
&complexFilters[filters][1][filters][1][filters][1][operator]=contains
&complexFilters[filters][1][filters][1][filters][1][value]=quarterly
&complexFilters[filters][2][column]=age
&complexFilters[filters][2][operator]=between
&complexFilters[filters][2][value]=18,65
&filters[full_name:contains]=Ali
&filters[email_address:ends]=@acme-corp.test
&select=id,first_name,company.name
&count=posts
&order[desc]=posts_count&order[asc]=company.name
The SQL this compiles to (SQLite; MySQL/PostgreSQL use their own quoting but the same shape) — a query nobody is hand-writing per endpoint:
select "users"."id", "users"."first_name", "users"."company_id", (select count(*) from "posts" where "users"."id" = "posts"."user_id") as "posts_count" from "users" left join "companies" as "TQkBE_companies" on "users"."company_id" = "TQkBE_companies"."id" where ( ("users"."status" not in ('banned', 'deleted')) and ( ( exists (select * from "companies" where "users"."company_id" = "companies"."id" and "companies"."name" = 'Acme Corp') and (select count(*) from "posts" where "users"."id" = "posts"."user_id" and "published_at" is not null) >= 2 ) or ( ("age" >= 18) and exists (select * from "posts" where "users"."id" = "posts"."user_id" and `posts`.`title` LIKE '%quarterly%' ESCAPE '\') ) ) and ("users"."age" between 18 and 65) ) and (`first_name` || ' ' || `last_name`) LIKE '%Ali%' ESCAPE '\' and `users`.`email` LIKE '%@acme-corp.test' ESCAPE '\' group by "users"."id" order by (`TQkBE_companies`.`name` IS NULL) ASC, `TQkBE_companies`.`name` ASC, (`posts_count` IS NULL) DESC, `posts_count` DESC
(the TQkBE_ prefix is a random alias the engine generates per-request so a
join never collides with another one in the same query; the mix of "..."
and `...` quoting is real too — SQLite's own grammar quotes with ",
while the identifiers this package resolves and escapes itself use `,
portable across every driver it supports)
Date shortcut + full-text-style relation search + counter, combined
"Posts published in the last 7 days, tagged
php, whose title mentions 'laravel'."
GET /posts
?filters[published_at:date_last_n_days]=7
&filters[tags.name:eq]=php
&filters[title:contains]=laravel
One line combines a rolling date window, a belongsToMany relation
condition (through the post_tag pivot table), and a plain text search —
three completely different SQL mechanisms (a bound date range, an EXISTS
subquery through a pivot join, a LIKE), selected automatically per field,
from three lines of query string.
Three relation hops deep, mixing a "through" relation and a pivot table
"Companies that have at least one post — reached through their users, a
hasManyThroughrelation — taggedurgent— reached through that post'sbelongsToManytags."
GET /companies?filters[users.posts.tags.name:eq]=urgent
One filter, three relation hops, two different relation types
(hasMany → belongsToMany, independent of the hasManyThrough shortcut
Company also exposes directly for reads) — resolved by calling the real
relation methods on each model in turn, never a hand-maintained join map.
Security model
- Every value is bound (
where,whereIn,whereBetween, parameterisedwhereRaw(... ? ...)) — never interpolated into SQL. LIKEwildcards incontains/starts/endsvalues are escaped, with a boundESCAPEcharacter portable across MySQL/SQLite/PostgreSQL.qreuses this same mechanism per field, so it carries the exact same guarantees.- Column/relation names coming from the request are validated against an
allowed character set and the model's own whitelist before ever reaching a
raw SQL fragment — an attempt like
order[asc]=id; DROP TABLE users;--is dropped, not executed. - A field/relation outside the whitelist is dropped silently (
filters,select,count,Model::sort()) or throwsInvalidArgumentException(OrderByCriteria::apply(), i.e. plainapplyCriteria()ordering) — picksort()overapplyCriteria()'s own order handling when you want disallowed input to degrade gracefully instead of failing the request.
Testing
composer install
vendor/bin/phpunit # or: vendor/bin/phpunit --testdox
Changelog
Please see CHANGELOG.md for more information on what has changed recently.
Security
If you discover any security related issues, please email cauesantosre4@gmail.com instead of using the issue tracker.