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:
{
"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_BOOLEAN — filter[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/yes → true (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'),
];
}
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
path — artist.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 path — WhereThrough::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 afilter[...]), but still drives the child's column — soContains::make('name')filters columnnamewhatever the group key is.
Fan-out search¶
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.'),
];
}
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),
);
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),
),
);
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— anarray<string, mixed>keyed by filter key, the spec-shaped projection on the operation'sQueryParametersvalue object;JsonApiRequestInterface::getFiltering()— the same map straight off the request (whatQueryParametersreads 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
sortparameter. - 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/DateRangedeepObjectparameter) are projected. - The Symfony bundle's data-layer docs cover the Doctrine execution side —
how each built-in and convenience filter (including the
EXISTSrelationship filters and the two-predicate ranges) translates to aQueryBuilderpredicate.