Route Configuration
Filtering is declared withfiltering() on the Route. One filter per field, with the operators that field accepts:
Operators
Operator holds every operator Apivalk knows. The constant value is also the wire name, so Operator::GTE is what a client sends as amount[gte]=10.
Each filter class supports the subset that makes sense for its type:
Passing an operator a class does not support throws when the route is built, as does declaring the same field twice or declaring a filter with no operator at all.
availableFilters() runs during routing and documentation generation, so a bad declaration surfaces on the first request rather than in production.
Filter field names must not collide with the query parameters the framework reads itself: order_by, offset, cursor, page and limit. A filter on one of those throws with the reserved list in the message.
Client-Side Usage
Bracket notation
The operator is the second bracket level:IN takes a comma-separated list, so a value containing a comma cannot be filtered with it. NULL takes a boolean rather than a value.
Flat notation
A shortcut that resolves to the first operator declared for that field:new EnumFilter($property, Operator::EQ, Operator::IN) that is status[eq]=active. Reorder the declaration and you change what flat notation means, so declare the operator you want as the default first.
Both notations produce the same FilterBag. Sending both for one field is a client-side contradiction that PHP resolves before Apivalk sees it, last one wins.
Validation
RequestValidationMiddleware answers with 422 when:
- the client sends an operator the field does not declare
- a value fails the property’s own validation (wrong type, outside
minimum, not in the enum, …) - two conditions can never match at once, for example
amount[gte]=100&amount[lte]=10,eqequal toneq, oreqoutside a declared range - an
INlist is empty
Usage in Controller
$request->filtering() returns the FilterBag. Every operator a field declares is a property on its filter, typed to the property behind it:
Beyond those properties a filter exposes three things, and nothing else your code should touch:
has(Operator::X)answers whether the client supplied that operator. Use it for boolean and numeric filters, wherefalseand0are legitimate values that!== nullreads awkwardly for.raw(Operator::X)returns the literal string the client sent, before casting. Useful for audit logs and error messages that quote the input.conditions()returns every supplied condition asoperator => cast value, for generic fan-out to a query builder.
FilterInterface are marked @internal: they exist for the population strategy, the validation middleware and the documentation generators.
Reading an operator the field does not declare throws a LogicException, as does an accessor that does not exist at all. Both are bugs in your code rather than client input, so they fail loudly on the first run.
If the client sent nothing, every property is null (or [] for in) and conditions() is empty.
QUERY requests
Add->enableQuery() to accept the same filters as an RFC 10008 QUERY request body:
FilterBag, same validation, and IN takes a real JSON array here rather than a comma-separated string, in the flat form ({"status": ["active", "inactive"]}) as well as the nested one. Sending body filters and query-string filters on one request is a 422.
It is opt-in per route: a gateway that does not know the method drops the request before it reaches your application, and generating it everywhere would make SDK generators emit two calls for the same read.
OpenAPI Documentation
A field with exactly one operator becomes a flat query parameter. There is no operator left to choose, so the bracket level is dropped and the schema is that operator’s schema directly:NULL is documented as a boolean. IN is documented as an array with style: form and explode: false, which is the OpenAPI spelling of ?status=draft,active and the only one that keeps the item constraints, an enum above all:
deepObject the same operator stays a comma-separated string, since deepObject is defined for one bracket level of primitive properties.
A field with two or more operators becomes one deepObject query parameter whose properties are the operators it declares:
minimum, maximum, pattern, enum) carry into each operator schema, and additionalProperties: false makes an undeclared operator a schema error rather than something a client discovers at runtime.
A property default is the one thing that does not carry over. On a parameter schema OpenAPI reads it as the value the server applies when the client omits the parameter, and an omitted filter adds no condition at all, so it is stripped.
A single bracket level with primitive properties is the case OpenAPI actually defines for deepObject, which is why filters are not nested under a shared filter key.
With ->enableQuery() the path also gets a query operation whose requestBody mirrors those schemas, one property per field: an object of operators for a multi-operator field, the operator’s schema directly for a single-operator one. That requires OpenAPI 3.2, which is what Apivalk emits.
Pass flatFilters: true to OpenAPIGenerator to flatten multi-operator fields as well, matching flat notation. Those parameters resolve to the field’s first declared operator and carry no operator hint in the description. See OpenAPI Generator → Filter Documentation Style.
IDE Support
DocBlockGenerator writes one interface per filter field, carrying only the operators that field declares:
created_at offers exactly those entries. The operator values are read through __get rather than declared as methods on the filter class, which is what keeps every other operator out of the list.
The filtering shape replaces FilterBag in the request docblock rather than being unioned with it, and carries the bag’s own API instead:
$filters->created_at through FilterBag::__get() as well as through the field shape, and report FilterInterface|null alongside it. For the same reason the controller has to name the concrete request class, otherwise ApivalkRequestInterface::filtering(): FilterBag puts that second path right back:
$request for why that is possible despite PHP’s rules on parameter types.