Declare filters with the AllowedFilter constructors. Each reads one filter[<name>] parameter; the full reference:
| Constructor | Request | Effect |
|---|---|---|
AllowedFilter::exact('status') | filter[status]=published | where('status', 'published'); a comma list becomes whereIn. |
AllowedFilter::boolean('active') | filter[active]=true | Exact match on a boolean column: true/false/1/0 (any case) compared as real booleans; any other value matches nothing. |
AllowedFilter::partial('title') | filter[title]=hello | Contains match (%hello%) — ILIKE on PostgreSQL, LIKE elsewhere — with escaped wildcards; case folding is the engine’s. |
AllowedFilter::beginsWith('code') | filter[code]=SKU | Anchored prefix match (SKU%), escaped, same case folding as partial. |
AllowedFilter::endsWith('code') | filter[code]=-01 | Anchored suffix match (%-01), escaped, same case folding as partial. |
AllowedFilter::operator('min_views', FilterOperator::GreaterThanOrEqual, 'views') | filter[min_views]=10 | Fixed server-side comparison: where('views', '>=', 10). |
AllowedFilter::operators('status', [RequestedOperator::Not]) | filter[status]=not:draft | The client picks the comparison from the declared set; a bare value still means equality. |
AllowedFilter::nullable('project', 'project_id', FilterValueShape::Uuid) | filter[project]=none | Exact match on a nullable column, with none for “unset” and not:none for “is set”. |
AllowedFilter::relation('label', 'labels', 'labels.id') | filter[label]=not:<id> | Matches through a relation — whereHas, and whereDoesntHave for a negation. |
AllowedFilter::jsonContains('tag', 'tags') | filter[tag]=release | Membership in a JSON array column; a negation means “none of these”. |
AllowedFilter::scope('published', booleans: true) | filter[published]=false | Calls the model scope scopePublished(); the value is passed as one argument (booleans: true makes true/false/1/0 real booleans). |
AllowedFilter::scope('between', spread: true) | filter[between]=10,100 | Calls the scope with the array spread across its arguments (opt-in). |
AllowedFilter::callback('min_views', $cb) | filter[min_views]=10 | Invokes $cb($query, $value, $property); takes booleans: true like scope(). |
AllowedFilter::trashed() | filter[trashed]=with | with includes trashed, only returns only trashed, anything else the default (needs SoftDeletes). |
AllowedFilter::custom('x', $filter) | filter[x]=… | Runs your own Filter implementation; takes booleans: true like scope(). |
Public names vs columns
Every constructor takes an optional internal name, so a public request key can map to a different column or scope — your schema never leaks into the URL and can change without breaking clients:
->allowedFilters(
AllowedFilter::exact('state', 'status'), // filter[state]=… → where('status', …)
AllowedFilter::partial('q', 'title'), // filter[q]=… → title LIKE %…%
AllowedFilter::scope('live', 'published'), // filter[live]=… → published(…)
)Exact and text matches
->allowedFilters(
'status', // shorthand for AllowedFilter::exact('status')
AllowedFilter::partial('title'), // filter[title]=hello → %hello%
AllowedFilter::beginsWith('sku'), // filter[sku]=AB → AB%
AllowedFilter::endsWith('email'), // filter[email][email protected] → %@example.com
)
// ?filter[status]=published,draft → whereIn('status', ['published', 'draft'])
// ?filter[title]=a,b → title LIKE %a% OR title LIKE %b%exact() compares with = and turns a comma list into whereIn. partial(), beginsWith() and endsWith() use ILIKE on PostgreSQL and LIKE elsewhere. User-typed % and _ are escaped and matched with an explicit ESCAPE clause on SQLite, MySQL and PostgreSQL, so a value can never widen the match. A comma list becomes a grouped OR.
Case folding is the engine’s
partial(), beginsWith(), endsWith() and the contains / ncontains / starts operators ignore letter case the way the database does, and the three engines differ outside plain ASCII:
| Engine | Operator | Case folding |
|---|---|---|
| PostgreSQL | ILIKE | Follows the database’s ctype locale: Unicode under a UTF-8 locale (ärger finds Ärger), ASCII only under C. Accents still count. |
| MySQL / MariaDB | LIKE | Follows the column’s collation: the default _ci collations fold Unicode case and accents (arger finds Ärger); a _bin / _cs collation is case-sensitive. |
| SQLite | LIKE | ASCII letters only: hello finds HELLO, but ärger does not find Ärger. |
Scopes
scope() calls the model scope named after the filter (camel-cased, so views_between calls viewsBetween). By default the normalised value is passed as a single argument — filter[published]=a,b calls the scope with ['a', 'b']:
// App\Models\Post
public function scopePublished(Builder $query, bool $published): void
{
$query->where('status', $published ? 'published' : 'draft');
}
public function scopeViewsBetween(Builder $query, int $min, int $max): void
{
$query->whereBetween('views', [$min, $max]);
}
// Controller
->allowedFilters(
AllowedFilter::scope('published', booleans: true), // filter[published]=false → published(false)
AllowedFilter::scope('views_between', spread: true), // filter[views_between]=10,100 → viewsBetween(10, 100)
)Scope spreading is opt-in. A single argument stops the request from controlling how many arguments a scope receives — otherwise it could inject an optional parameter of a scope such as scopeSearch($q, $term, $column = 'title'). Enable spread: true only for a scope you own whose arguments map to a comma/array value.
Boolean values
true and false stay text — a title search for “false” is a search — unless the filter says its value is a boolean: AllowedFilter::boolean(), a FilterValueShape::Boolean, or booleans: true on scope(), callback() or custom(). Those turn each true/false/1/0 (any case) into a real boolean and leave every other value as it was. Without it a scope receives the string, and PHP reads 'false' as truthy:
// Post::scopePublished(Builder $query, bool $published)
->allowedFilters(
AllowedFilter::boolean('active'), // filter[active]=false
AllowedFilter::scope('published', booleans: true), // filter[published]=false → false
)
// ?filter[active]=TRUE → active = true
// ?filter[active]=0 → active = false
// ?filter[active]=true,false → either
// ?filter[active]=maybe → matches nothing (no driver error)boolean() is shorthand for operators() with no operators and FilterValueShape::Boolean — use that directly to also offer not: (see Value shapes). A value that is not a boolean spelling matches nothing instead of reaching the driver, where PostgreSQL would answer with an error.
Callbacks
use Illuminate\Database\Eloquent\Builder;
->allowedFilters(
AllowedFilter::callback('min_views', function (Builder $query, mixed $value, string $property): void {
$query->where('views', '>=', $value);
}),
)The callback receives the query, the normalised value and the internal name. For a plain comparison, prefer AllowedFilter::operator() over a hand-written callback (see Operator filters).
Soft deletes
// Post uses Illuminate\Database\Eloquent\SoftDeletes
->allowedFilters(AllowedFilter::trashed())
// ?filter[trashed]=with → include soft-deleted rows
// ?filter[trashed]=only → only soft-deleted rows
// any other value → the default: non-trashed rows onlytrashed() needs a model using SoftDeletes. The only mode honours a custom soft-delete column. The filter name defaults to trashed; pass another name as the first argument.
Value normalisation
Values are normalised once, before any filter runs — a comma list becomes an array and one level of array nesting is flattened; the values themselves stay strings:
| Request | Value the filter receives | Note |
|---|---|---|
filter[status]=published | 'published' | A single string. |
filter[status]=published,draft | ['published', 'draft'] | A comma list becomes an array. |
filter[active]=true | 'true' | true/false stay text — only a filter that opts in (boolean(), FilterValueShape::Boolean, booleans: true) receives a real boolean. |
filter[status][]=a&filter[status][]=b,c | ['a', 'b', 'c'] | One level of array nesting is flattened. |
Request limits bound the work: at most limits.max_filter_values items per value (default 50) and limits.max_value_length characters per item (default 255) — the excess is dropped or truncated.
Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.