Skip to content

Filtering collections

This page shows you how to declare which filter[…] parameters a collection endpoint accepts, what the built-in catalogue gives you, and how to write a custom filter for behaviour the built-ins don't cover. By the end you'll be able to expose a text search, a boolean flag with a default, a set-membership filter, and a geo predicate — each declared as metadata, executed by a data-layer handler.

In this library a filter is metadata only: a small value object that names the filter[<key>] parameter and the column (or relationship) it targets, but carries no behaviour. The work of turning a filter into a query — a WHERE clause, an array predicate, a search call — lives in an adapter-provided FilterHandlerInterface, not in the filter itself. Here "adapter" means a data-layer integration — a Doctrine/Eloquent backend or the in-memory reference — not the request-lifecycle Psr7ToOperationHandlerAdapter from architecture. This is the same metadata/handler split that constraints and sorts use: core ships typed metadata, the data layer ships the translators that execute it. The library never reads filters() and applies it for you.

You declare filters here; a handler executes them. If you have not built one yet, core ships a reference ArrayFilterHandler (and the example catalog's CriteriaApplier wires it) — see Adapters for the minimal executing side. The rest of this page is purely the declaration side.

A worked filter: searching tracks by title

The TrackResource exposes a case-insensitive substring search on title plus an explicit flag that defaults to off. You declare both by overriding filters():

use haddowg\JsonApi\Resource\Filter\Where;
use haddowg\JsonApi\Resource\Filter\WhereIn;

public function filters(): array
{
    // `like`: a case-insensitive substring match on title (the operator is the
    // third make() argument — there is no fluent operator() setter).
    // `explicit` coerces the request value to a real bool and defaults to false
    // when the key is absent. `genres` matches a membership set.
    return [
        Where::make('title', 'title', 'like'),
        Where::make('explicit')->asBoolean()->default(false),
        WhereIn::make('genres'),
    ];
}

A request applying the title filter:

GET /tracks?filter[title]=android&filter[explicit]=true
{
  "data": [
    { "type": "tracks", "id": "2", "attributes": { "title": "Paranoid Android", "explicit": true, "…": "…" } }
  ]
}

The explicit=true here is doing real work: Where::make('explicit')->asBoolean()->default(false) means a plain GET /tracks excludes explicit tracks, so to surface "Paranoid Android" (the one explicit track) you have to override that default by presence. The default() value round-trips as a real bool because asBoolean() coerces the request value through FILTER_VALIDATE_BOOLEANfilter[explicit]=false selects the non-explicit tracks. The FiltersTest witnesses the substring match, the boolean round-trip, and the default exclusion/override pair.

filters() is purely declarative: it tells consumers (and a handler) which filter[<key>] parameters are legal for this type and how each maps to a target. The default is no filters.

The FilterInterface contract

Every filter implements Resource\Filter\FilterInterface, whose two members are the parameter key and the declared value constraints:

interface FilterInterface
{
    public function key(): string;

    /** @return list<ConstraintInterface> */
    public function constraints(): array; // default []
}

Concrete filters add their own public readonly fields (column, operator, delimiter, …) that a handler reads. They are final readonly value objects constructed through make(), with immutable with-style refinement helpers that each return a new instance. constraints() declares the value constraints a framework adapter validates a client-supplied value against before the filter reaches the data layer — see Validating filter values.

Structured parameters: DescribesQueryParameter

By default a filter's OpenAPI filter[<key>] parameter is a scalar whose value schema is projected from its constraints(). A filter with a structured wire shape — a nested object like Range's filter[<key>][min]/[max], or a comma-list — declares its own OpenAPI parameter envelope by implementing Resource\Filter\DescribesQueryParameter:

public function describeQueryParameter(Schema $valueSchema): QueryParameterShape
{
    // Wrap the constraint-derived value schema and pair it with an OAS style.
    return new QueryParameterShape(
        Schema::ofType('array')->withItems($valueSchema),
        ParameterStyle::Form,
        false, // explode
    );
}

The projector hands you the value schema it already built from your constraints(); you wrap it (into an object's min/max, an array's items, …) and set the OAS style/explode. This is what lets a custom filter with a non-scalar value document correctly — the same self-describing seam constraints use for their JSON Schema keyword (ProvidesJsonSchema), one level up at the parameter. Range/DateRange implement it; every other built-in is a plain scalar.

The built-in catalogue

All built-ins live in haddowg\JsonApi\Resource\Filter. Each make() defaults its target column (or relationship) to the filter key when you omit it.

Filter make() signature Targets Capabilities
Where make(string $key, ?string $column = null, string $operator = '=') a column comparison; singular(), deserializeUsing()/asBoolean(), default()
WhereIn make(string $key, ?string $column = null) a column value in a set; singular(), delimiter(), default()
WhereNotIn make(string $key, ?string $column = null) a column negation of WhereIn; singular(), delimiter(), default()
WhereIdIn make(string $key = 'id', string $column = 'id') the id id in a set; delimiter(), default()
WhereIdNotIn make(string $key = 'id', string $column = 'id') the id negation of WhereIdIn; delimiter(), default()
WhereNull make(string $key, ?string $column = null) a column column is null (presence-only)
WhereNotNull make(string $key, ?string $column = null) a column column is not null (presence-only)
WhereHas make(string $key, ?string $relationship = null) a relationship has a related record (presence-only)
WhereDoesntHave make(string $key, ?string $relationship = null) a relationship negation of WhereHas (presence-only)
WhereThrough make(string $key, ?string $path = null) a relationship path compares a value reached through a dotted path; operator(), deserializeUsing(), constrain(...) and the value-constraint shortcuts (see Traversing a relationship path)

Where carries the comparison operator as its third make() argument — = (the default), like, starts, ends, >, >=, <, <=, != (with <> as an alias), ===. The reference ArrayFilterHandler maps each to a PHP comparison (like is a case-insensitive stripos, matching what a SQL LIKE '%…%' gives on common backends; starts/ends are the case-insensitive prefix/suffix variants, LIKE '…%' / LIKE '%…'); a database adapter translates the same operator strings into its own dialect.

Convenience filters

Reaching for Where::make('title', 'title', 'like') — or wiring the coercion, the value constraint and the OpenAPI value schema by hand for a numeric or boolean column — is repetitive and easy to get subtly wrong. The library ships a small catalogue of intent-named filters that name what you mean and bundle the matching operator, value coercion, value constraint and OpenAPI description into a single declaration. Each lives in haddowg\JsonApi\Resource\Filter; each make() defaults its target column to the filter key.

Filter Intent Presets
Contains substring match like operator
StartsWith prefix match starts operator
EndsWith suffix match ends operator
Numeric numeric equality =, numeric coercion + numeric() constraint
GreaterThan strictly greater >, numeric coercion + numeric()
GreaterThanOrEqual at least >=, numeric coercion + numeric()
LessThan strictly less <, numeric coercion + numeric()
LessThanOrEqual at most <=, numeric coercion + numeric()
Boolean boolean match =, boolean coercion (asBoolean()) + boolean()
Range inclusive min ≤ x ≤ max numeric coercion + numeric(); nested {min, max} value
DateRange inclusive date/time range ISO-8601 → \DateTimeImmutable coercion; nested {min, max} value
use haddowg\JsonApi\Resource\Filter\Contains;
use haddowg\JsonApi\Resource\Filter\GreaterThanOrEqual;
use haddowg\JsonApi\Resource\Filter\Boolean;
use haddowg\JsonApi\Resource\Filter\Range;

public function filters(): array
{
    return [
        Contains::make('title'),                       // filter[title]=android
        GreaterThanOrEqual::make('minYear', 'year'),   // filter[minYear]=1997, compared numerically
        Boolean::make('explicit'),                     // filter[explicit]=true, coerced to a real bool
        Range::make('duration'),                        // filter[duration][min]=120&filter[duration][max]=300
    ];
}

The scalar convenience filters (Contains through Boolean) are thin Where subclasses: they preset the operator, a typed value deserializer and the matching value constraint, so a handler's existing instanceof Where arm dispatches them unchanged — no new handler arm needed, and both the reference ArrayFilterHandler and a database adapter run them out of the box. Because they are a Where, they compose with its refinement helpers (default(), singular(), extra constrain(...)), and each carries its own OpenAPI value schema and a generated description ("Matches values greater than or equal to the given number.", …) which you can override with describedAs().

Their operator is fixed — it is the filter's identity — so the third make() argument exists only for signature parity with Where::make(), and passing a different operator (GreaterThan::make('age', 'age', '<')) is a loud \InvalidArgumentException, never a silently-ignored value.

The value proposition over a bare Where is real: Numeric/GreaterThan/… coerce the incoming string to int/float before comparison, so filter[age]=6 keeps 18 but not 5 (a lexical string compare would wrongly keep 5); Boolean coerces 1/true/on/yestrue (and 0/false/off/no/''false) so a truthy string compares as a real boolean; and each also declares the matching value constraint, so a mistyped value (filter[year]=banana) is a clean 400 before the filter reaches the data layer (see Validating filter values).

Ranges

Range and DateRange are genuinely new filter types, not Where presets: their wire value is nested?filter[<key>][min]=…&filter[<key>][max]=… (Symfony parses this into ['min' => '…', 'max' => '…']) — and an apply runs two predicates (min ≤ x and x ≤ max), so a handler needs a dedicated instanceof Range arm (the reference ArrayFilterHandler ships one; an OpenAPI projection advertises the bounds as a deepObject parameter).

Either bound may be omitted, so an open-ended range works — min alone is a >=, max alone a <=, and an entirely absent value is a no-op:

GET /tracks?filter[duration][min]=120&filter[duration][max]=300   # 120 ≤ duration ≤ 300
GET /tracks?filter[duration][min]=120                             # duration ≥ 120
GET /albums?filter[released][min]=1990-01-01&filter[released][max]=1999-12-31

Range's preset deserializer coerces each bound and the column value to a number before comparing (so the range is numeric, not lexical); DateRange is a Range whose deserializer coerces each ISO-8601 bound to a \DateTimeImmutable (and the column value likewise), so published before/after reads as one key and the comparison is temporal. Each present bound is validated (numeric for Range, an ISO-8601 shape for DateRange), so a malformed bound is a clean 400 rather than a silent non-match. Like WhereThrough these are data-layer-specific: core ships the metadata and the reference in-memory apply, and a database adapter translates each into two push-down andWhere predicates.

Refinement helpers

Each helper returns a new instance — the value objects are immutable — and only the filters that carry the helper expose it:

Helper On Effect
singular() Where, WhereIn, WhereNotIn marks a zero-to-one result (see Singular filters)
delimiter(string) WhereIn, WhereNotIn, WhereIdIn, WhereIdNotIn overrides the default , a string value is split on
deserializeUsing(\Closure) Where a value transformer applied before comparison
asBoolean() Where a shortcut for deserializeUsing() coercing via FILTER_VALIDATE_BOOLEAN
default(mixed) Where, WhereIn, WhereNotIn, WhereIdIn, WhereIdNotIn a value to apply when the key is absent (see Default values)
fixed(mixed) Where (and its convenience subclasses) pins the compared value so the request value is ignored and the key becomes a presence trigger (see Fixed values)
constrain(...), numeric(), integer(), uuid(?int), boolean(), pattern(string) Where, WhereIn, WhereNotIn, WhereIdIn, WhereIdNotIn declares value constraints validated before the filter runs (see Validating filter values)
use haddowg\JsonApi\Resource\Filter\Where;
use haddowg\JsonApi\Resource\Filter\WhereIn;

Where::make('explicit')->asBoolean();
Where::make('createdAfter', column: 'created_at', operator: '>')
    ->deserializeUsing(static fn(mixed $v): \DateTimeImmutable => new \DateTimeImmutable((string) $v));
WhereIn::make('genres')->delimiter('|');

The presence-only filters (WhereNull, WhereNotNull, WhereHas, WhereDoesntHave) carry no refinement helpers: their requested presence is their semantics.

Singular filters

A filter on a unique attribute — a slug, a UUID — matches at most one resource. Marking it singular() declares that zero-to-one shape, but whether the collapse to a single object actually happens is up to your handler: the metadata says "this match is zero-to-one", and a handler that honours the flag returns a single resource object (or null) in data, not an array. The reference catalog handler narrows to the matching artist but still renders a collection — so the responses below show the intended collapse a handler that honours the flag would produce, not what the example catalog returns today. The ArtistResource declares one on slug:

use haddowg\JsonApi\Resource\Filter\Where;

public function filters(): array
{
    // singular(): GET /artists?filter[slug]=radiohead collapses a unique
    // match to a single resource object (or null), not a collection.
    return [
        Where::make('slug')->singular(),
    ];
}
GET /artists?filter[slug]=radiohead    →  { "data": { "type": "artists", "id": "1", … } }
GET /artists?filter[slug]=does-not-exist →  { "data": null }
GET /artists                            →  { "data": [ … ] }   // normal collection

The collapse applies only when the client actually sends the singular filter (otherwise the usual zero-to-many collection is returned), and has no effect on relationship endpoints. singular() is metadata: a filter declares it by implementing the Resource\Filter\SupportsSingular capability interface (isSingular()), and the collection handler reads it for an applied filter and renders the first match (or null). A custom filter opts in by implementing SupportsSingular itself.

The reference MusicCatalogHandler is the example here: it applies the slug predicate (narrowing to the matching artist) but does not yet perform the single-resource collapse — the collapse is a handler-level affordance and the metadata is in place for an adapter to honour.

Default values

A value-carrying filter can declare a default: the value to apply when the request doesn't carry its filter[<key>] parameter at all.

use haddowg\JsonApi\Resource\Filter\Where;
use haddowg\JsonApi\Resource\Filter\WhereIn;

Where::make('explicit')->asBoolean()->default(false); // GET /tracks → explicit tracks excluded
WhereIn::make('tags')->default('new,featured');        // shaped as the request would carry it

A default is a convenience the client can override, never a constraint it cannot: a requested key always wins, and it wins by presence (array_key_exists) — an explicit empty or null value (filter[explicit]=) still overrides the default. An empty value is not a no-op: under an asBoolean() / boolean() filter the empty string coerces through FILTER_VALIDATE_BOOLEAN to boolean false (and passes the boolean() value constraint, so it is never a 400), so filter[active]= matches the falsy rows — this is deliberate and frozen. Anything the client must not be able to undo (soft-delete exclusion, tenant scoping) belongs in your data layer, not the filter vocabulary. Shape a set filter's default exactly as the request would carry it — an array or a delimited string honouring the filter's delimiter().

Defaulting filters implement the Resource\Filter\HasDefaultValue capability interface (hasDefault() + defaultValue() — a dedicated flag, because null is a legitimate default). The presence-only filters deliberately don't participate: a "default" there would be an always-on constraint in disguise.

Like everything else about filters, a default is metadata — whoever matches requested keys to declared filters folds the defaults in first, through FilterDefaults::apply(), so the presence semantics are decided once and every adapter agrees on them:

use haddowg\JsonApi\Resource\Filter\FilterDefaults;

$requested = FilterDefaults::apply($request->getFiltering(), $resource->filters());

When two declared filters share a key, the first wins — the same first-match rule a handler uses to resolve a requested key to its declared filter. A custom filter opts in by implementing HasDefaultValue itself.

Fixed values

->fixed($value) pins the compared value on a Where (or any convenience subclass): the request value is ignored and the key becomes a presence trigger — sending filter[<key>] with any value applies column <operator> <value>, and omitting the key does not apply it at all.

use haddowg\JsonApi\Resource\Filter\Where;
use haddowg\JsonApi\Resource\Filter\GreaterThan;

Where::make('status')->fixed('published');   // filter[status]=<anything> → status = 'published'
GreaterThan::make('priority')->fixed(5);      // filter[priority]=<anything> → priority > 5

Contrast it with default():

->default($v) ->fixed($v)
Applies when the key is absent yes (folds $v in) no
Applies when the key is present yes, with the client's value yes, always with $v
Client can override the value yes — a sent value wins no — the sent value is ignored

A default is a convenience the client can override; a fixed value is one it cannot influence at all. (Anything the client must be unable to remove — a soft-delete exclusion, tenant scoping — still belongs in your data layer, not a presence-triggered filter the client chooses whether to send.)

->fixed() needs no handler support of its own: it routes execution through the existing value-deserializer seam (the compared value becomes a constant), so the built-in Where arm — in the reference ArrayFilterHandler and in a Doctrine or Eloquent adapter — runs it unchanged. Because the request value carries no meaning, any declared value constraints are dropped (there is nothing client-supplied to validate), and the OpenAPI generator documents the parameter honestly as server-applied — its presence applies the filter, its value is ignored.

Validating filter values

A filter is metadata only: nothing about Where::make('age', 'age') says the value must be a number. Without a declared constraint a mistyped value (GET /tracks?filter[duration]=banana against an integer column) flows straight to the data layer and gets the adapter's unhelpful default: the in-memory handler and a loosely-typed database (sqlite) simply never match, while a strict driver such as Postgres raises a PDO type error (a 500). Declaring a value constraint turns that into a clean 400, validated before the filter reaches the data layer.

Declare constraints on a value-carrying filter (Where, WhereIn, WhereNotIn, WhereIdIn, WhereIdNotIn) with constrain() or the type shortcuts — immutable withers that append the matching core constraint, mirroring the Id field's uuid() / numeric() / pattern():

use haddowg\JsonApi\Resource\Filter\Where;
use haddowg\JsonApi\Resource\Filter\WhereIdIn;
use haddowg\JsonApi\Resource\Constraint\Min;

public function filters(): array
{
    return [
        Where::make('year')->integer(),                 // ^-?[0-9]+$
        Where::make('rating')->numeric(),               // integer or decimal
        Where::make('active')->boolean(),               // 1/0, true/false, on/off, yes/no, ''
        Where::make('isrc')->pattern('^[A-Z]{2}.{10}$'),// any ECMA-262 regex
        WhereIdIn::make()->uuid(4),                      // a UUIDv4 id set
        Where::make('listens')->constrain(new Min(0)),   // any core ConstraintInterface
    ];
}
Shortcut Appends Wire form it accepts
numeric() Pattern('^-?[0-9]+(?:\.[0-9]+)?$') an integer or decimal
integer() Pattern('^-?[0-9]+$') an integer
uuid(?int $version = null) UuidFormat($version) a UUID (optionally a specific version)
boolean() Pattern('^\s*(?i:true\|false\|1\|0\|on\|off\|yes\|no)\s*$\|^\s*$') the FILTER_VALIDATE_BOOLEAN set: 1/0, true/false, on/off, yes/no, or the empty string — case-insensitive, surrounding whitespace tolerated
pattern(string $regex) Pattern($regex) a value matching the ECMA-262 regex
constrain(ConstraintInterface ...) the given constraints as the constraint describes

constrain() and the shortcuts reuse the same constraint vocabulary as fields, so any Resource\Constraint\ConstraintInterface (Min, Max, In, …) works too.

Constraints are metadata only — core never executes them. A framework adapter (the Symfony bundle) translates them to its native validator and checks the client-supplied value, throwing Exception\FilterValueInvalid on a violation:

{
  "errors": [
    {
      "status": "400",
      "code": "FILTER_VALUE_INVALID",
      "title": "Filter value is invalid",
      "detail": "This value should be of type integer.",
      "source": { "parameter": "filter[year]" }
    }
  ]
}

It is a 400 — a bad query parameter, located by source.parameter, not a 422 (which is reserved for document semantic errors located by source.pointer). One error is rendered per violation. Only a client-supplied value is validated; a filter's author-set default() is trusted and never checked. A filter that declares no constraints behaves exactly as before. Validation is provider-agnostic — it runs on the value, before any handler — so both the in-memory and database adapters get the same clean 400 instead of the silent non-match (or, on a strict driver, the downstream 500) an unvalidated value would yield. The check is opt-in on the adapter's validator integration: with no validator installed a filter that declares constraints behaves as if it had none (see the bundle's filter docs).

Relationship-existence filters

WhereHas and WhereDoesntHave filter the collection by whether each row has a related record, rather than by a column value. The AlbumResource exposes albums that have at least one track:

use haddowg\JsonApi\Resource\Filter\WhereHas;

public function filters(): array
{
    // WhereHas('tracks'): albums that have at least one related track. The
    // relationship key reads $album->tracks directly (a Doctrine adapter would
    // render an EXISTS subquery over the same relation).
    return [
        WhereHas::make('tracks'),
    ];
}
GET /albums?filter[tracks]=1   →  albums with at least one track

Core ships only the metadata. The reference ArrayFilterHandler tests the related value for non-empty/non-null (the request value is irrelevant — presence alone decides), and a Doctrine adapter renders an EXISTS / NOT EXISTS subquery over the same relationship. See Adapters for the handler side.

Traversing a relationship path

WhereThrough filters on a value reached by walking a dotted relationship pathartist.name keeps a row whose artist's name matches, comments.body keeps a row that has some comment whose body matches, author.company.name chains the hops. Every intermediate segment is a relationship (to-one or to-many — both read identically as "there exists a … whose …") and the final segment is the compared attribute. It is an EXISTS-ANY semi-join: a database adapter interprets the path as a correlated EXISTS subquery, never a fetch-join, so it neither hydrates the relation nor multiplies rows. The AlbumResource exposes albums by their artist's name:

use haddowg\JsonApi\Resource\Filter\WhereHas;
use haddowg\JsonApi\Resource\Filter\WhereThrough;

public function filters(): array
{
    return [
        WhereHas::make('tracks'),
        // EXISTS-ANY over album->artist->name; no fetch-join (the Doctrine
        // reference renders a correlated EXISTS).
        WhereThrough::make('artist.name'),
    ];
}
GET /albums?filter[artist.name]=Radiohead   →  one album: id 1 "OK Computer"
GET /albums?filter[artist.name]=Portishead  →  one album: id 2 "Dummy"

The single-argument form uses the dotted path as both the wire key and the traversal pathWhereThrough::make('artist.name') responds to filter[artist.name]. Supply a second argument to decouple them, exposing a clean wire key over a deeper path:

use haddowg\JsonApi\Resource\Filter\WhereThrough;

// filter[topArtist]=Radiohead, traversing album->artist->name
WhereThrough::make('topArtist', 'artist.name');

Both positional slots are taken by the key and the path, so the comparison operator is the fluent operator() setter rather than a make() argument (default =). It draws the same vocabulary as Where=, != (alias <>), >, >=, <, <=, like — applied at the leaf segment:

use haddowg\JsonApi\Resource\Filter\WhereThrough;

WhereThrough::make('artist.foundedYear')->operator('>=');

WhereThrough carries value constraints the same way the column filters do — constrain(...) or the type shortcuts (integer(), numeric(), uuid(), boolean(), pattern()) append the matching constraint so a mistyped value is a clean 400 before the filter reaches the data layer — and a deserializeUsing() transformer applied before comparison, exactly as on Where.

WhereHas and WhereDoesntHave are the degenerate length-1 case of this same traversal: a single relationship segment with no leaf comparison, where presence alone (a non-empty related collection or a non-null to-one) decides the match. The reference ArrayFilterHandler folds both onto the one WhereThrough traversal walk — WhereHas is "the path reaches at least one present value", and a Doctrine adapter renders all three as the same EXISTS shape. The FiltersTest witnesses the artist.name traversal narrowing albums to each artist's catalogue.

List and set values

A set filter (WhereIn, WhereNotIn, the id variants) treats its incoming value as a list — either an already-array value (filter[genres][]=a&filter[genres][]=b) or a delimited string (filter[genres]=a,b, split on the configured delimiter()). The split happens in the handler: the reference ArrayFilterHandler consults $filter->delimiter (defaulting to ,) and treats an array value as already-split.

Filter groups: WhereAll / WhereAny

By default distinct filters combine with an implicit AND across separate filter[<key>] keys. When you need OR across columns, or a single named condition that bundles several comparisons, compose a filter group: WhereAll (AND) and WhereAny (OR) are value objects carrying a key() and a list<FilterInterface> of children. The group is composed server-side by the resource author — the client picks whether to send filter[<key>], never how the boolean algebra is assembled.

Two properties make groups expressive:

  • The group passes its own request value to every child. So a group of value-carrying children fans one value across columns.
  • A child's own key() is ignored as a request parameter (only the group's key is a filter[...]), but still drives the child's column — so Contains::make('name') filters column name whatever the group key is.

WhereAny over several Contains children is a multi-column search — one value, many columns:

use haddowg\JsonApi\Resource\Filter\WhereAny;
use haddowg\JsonApi\Resource\Filter\Contains;

public function filters(): array
{
    return [
        WhereAny::make('q', Contains::make('name'), Contains::make('email'))
            ->describedAs('Search by name or email.'),
    ];
}
GET /users?filter[q]=foo   →  name LIKE '%foo%' OR email LIKE '%foo%'

A fanning group declares its shared value's constraints exactly like a Where (->numeric(), ->pattern(...), ->constrain(...)), and the OpenAPI generator projects it as a single scalar filter[q] value parameter.

Canned toggle

Combine ->fixed() children and the group becomes a canned toggle — its presence applies a fixed, multi-condition predicate and the request value is ignored:

use haddowg\JsonApi\Resource\Filter\WhereAll;
use haddowg\JsonApi\Resource\Filter\GreaterThan;
use haddowg\JsonApi\Resource\Filter\Boolean;

WhereAll::make('urgent',
    GreaterThan::make('priority')->fixed(5),
    Boolean::make('flagged')->fixed(true),
);
GET /tickets?filter[urgent]=1   →  priority > 5 AND flagged = true

Because every child is fixed, the group is presence-triggered: the OpenAPI generator documents filter[urgent] as server-applied (value ignored), and a group whose children all fix their value declares no value constraints of its own.

Nesting

Groups nest arbitrarily — a group child is itself a FilterInterface, so it re-enters the same dispatch — letting the author compose (A AND (B OR C)):

use haddowg\JsonApi\Resource\Filter\WhereAll;
use haddowg\JsonApi\Resource\Filter\WhereAny;
use haddowg\JsonApi\Resource\Filter\Contains;
use haddowg\JsonApi\Resource\Filter\Boolean;
use haddowg\JsonApi\Resource\Filter\GreaterThan;

WhereAll::make('search',
    Contains::make('name'),                       // fans the request value
    WhereAny::make('inner',
        Boolean::make('flagged')->fixed(true),    // ignores it
        GreaterThan::make('priority')->fixed(100),
    ),
);
GET /users?filter[search]=bob
    →  name LIKE '%bob%' AND (flagged = true OR priority > 100)

This stays owner-vetted: the client cannot assemble arbitrary boolean algebra (no client-driven filter[and]/[or]), so it does not widen the allow-list the group defines. See ADR 0129.

Each provider runs a group through one recursive handler arm that combines its children with AND/OR — the reference ArrayFilterHandler ships one, and the Doctrine and Eloquent adapters each add their own (andX()/orX(), nested where(fn ($q) => …)). A child the handler doesn't recognise resolves through the same custom-filter fallthrough it would at the top level.

Writing a custom filter

When the built-ins don't cover a predicate, you write a value object implementing FilterInterface and carry whatever fields your handler needs to execute it. The WithinRadius geo filter names the latitude/longitude columns to read off each row:

use haddowg\JsonApi\Resource\Filter\FilterInterface;

final readonly class WithinRadius implements FilterInterface
{
    public function __construct(
        public string $key,
        public string $latColumn,
        public string $lngColumn,
    ) {}

    public static function make(string $key, string $latColumn = 'latitude', string $lngColumn = 'longitude'): self
    {
        return new self($key, $latColumn, $lngColumn);
    }

    public function key(): string
    {
        return $this->key;
    }
}

List it in a Resource's filters() like any built-in. For it to do anything, a handler must recognise it — the reference ArrayFilterHandler doesn't, so the catalog's CriteriaApplier carries a matching execution arm, exactly as a Doctrine adapter would add an arm of its own:

$rows = $filter instanceof WithinRadius
    ? $this->withinRadius($rows, $filter, $value)
    : $this->delegateFilter($filter, $rows, $value);

A custom filter and the handler that understands it are written together: a FilterHandlerInterface that receives a FilterInterface it doesn't know throws UnsupportedFilter. The handler side — including the worked withinRadius() arm — is covered in Adapters.

Unsupported filters

When a FilterInterface reaches a FilterHandlerInterface that doesn't recognise it, Resource\Filter\UnsupportedFilter is thrown. This is a server configuration error, not a client error — a filter was declared (or routed through) with no handler wired to execute it — so it renders as a 500. It is an AbstractJsonApiException like the rest of the exception hierarchy, so the error-handler middleware turns it into a JSON:API error document automatically. It exposes the offending filter via its public $filter property.

An undeclared filter key (one no resource lists) is a different case: it is silently ignored. The library never auto-applies filters, and a handler only dispatches the declared VOs it is handed — so GET /artists?filter[withinRadius]=51.5 against a resource that doesn't declare withinRadius returns the full collection, unnarrowed.

That silent-ignore is key-level — an unrecognized key inside the filter family. The filter family itself is always recognized, so it never trips the strict query-parameter validation that Server runs by default: an unrecognized query-parameter family (a misspelled ?fitler[...], an unregistered custom parameter) is a 400 QueryParamUnrecognized, distinct from this key-level tolerance within a recognized family.

Reading requested filters off the operation

From an operation handler you read the requested filter[…] map two ways:

  • $operation->queryParameters()->filter — an array<string, mixed> keyed by filter key, the spec-shaped projection on the operation's QueryParameters value object;
  • JsonApiRequestInterface::getFiltering() — the same map straight off the request (what QueryParameters reads from).

You then match each present key to the declared filter and hand both to the handler. The CriteriaApplier shows the full loop: fold defaults in with FilterDefaults::apply(), index the declared filters by key (first wins for a shared key), then for each requested key look up its declared filter and dispatch:

$requested = $foldDefaults
    ? FilterDefaults::apply($request->getFiltering(), $resource->filters())
    : $request->getFiltering();

foreach ($requested as $key => $value) {
    $filter = $declared[$key] ?? null;
    if ($filter === null) {
        continue; // an undeclared key is silently ignored
    }
    // … dispatch $filter + $value to the handler …
}

Next / See also

  • Adapters — the handler side: applying filters against your data layer, the reference ArrayFilterHandler, and a worked custom-filter arm.
  • Sorts — the same metadata/handler split for the sort parameter.
  • Pagination — windowing a filtered, sorted collection.
  • Resource classes — declaring filters() on a resource type.
  • Validation — the constraint metadata this pattern mirrors.
  • OpenAPI generation — how a filter's value schema and description (including a Range/DateRange deepObject parameter) are projected.
  • The Symfony bundle's data-layer docs cover the Doctrine execution side — how each built-in and convenience filter (including the EXISTS relationship filters and the two-predicate ranges) translates to a QueryBuilder predicate.