---
title: "Filters — Query Builder for Laravel | Roundly"
description: "Every built-in filter constructor — exact, boolean, partial, prefix/suffix, scope, callback, trashed and custom — plus value normalisation."
url: https://roundly-consulting.com/open-source/docs/query-builder-for-laravel/filters
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Query Builder for Laravel](https://roundly-consulting.com/open-source/docs/query-builder-for-laravel.md)

# Filters

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::search('search', ['name', 'email'])` | `filter[search]=ann` | One search box across several columns: rows where any of them contains the phrase, as one grouped OR. A comma is part of the phrase. See Search across columns. |
| `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:

```php
->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

```php
->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]=@example.com → %@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. For one search box across several columns, where a comma is part of the text, use search() — see Search across columns.

## Case folding is the engine’s

partial(), beginsWith(), endsWith(), search() 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'\]:

```php
// 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:

```php
// 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

```php
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

```php
// 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 only
```

trashed() 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. |

An empty element is no value: a,,b and a,b, are two values, while filter\[status\]=, filter\[status\]=, and an operator with nothing after it (not:, contains:) carry none. A filter left with no value adds no constraint — exact, boolean, partial, beginsWith, endsWith, search, operator, operators, nullable, relation and jsonContains alike — so a cleared search box or a stray comma never empties or skews the list. scope(), callback() and custom() receive the value as sent.

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 crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## 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.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/query-builder-for-laravel.md)
