Filters
The filters section defines which columns can be filtered and what strategy
to use. Filters are applied via query parameters.
Each filterable column maps to a filter class. Use the shorthand (class name only) or the options form (two-element array with class + config):
Built-in filter classes
| Class | Description | Options |
|---|---|---|
ExactFilter | WHERE column = value (IN (…) for a list) | negate, separator, maxValues, type |
PartialFilter | WHERE column LIKE %value% | negate, separator, maxValues |
WordStartFilter | WHERE column LIKE value% | negate, separator, maxValues |
RangeFilter | Comparison operators on a column (numeric, string or date) | Value must be ['gte'=>…, 'lte'=>…, 'gt'=>…, 'lt'=>…]. The bound
parameter type is inferred from the column's TCA configuration
(number, datetime, …); the optional type
(int | float | string | date | datetime) overrides
it. |
SearchFilter | OR across multiple columns (LIKE) | columns (required), match (partial | word_start, default
partial), negate, separator, maxValues |
MmFilter | Subquery via MM intermediate table | mm_table, mm_local_key, mm_foreign_key, mm_constraints
(derived from TCA when omitted), match (any | all, default
any), negate, separator, maxValues |
negate, separator and maxValues are described under
Multiple values per filter (IN / NOT IN). The supported type options and TCA type
mapping are described under Range filter. Both ExactFilter and
RangeFilter prefer an explicit type option, then a TCA-derived type.
Their fallback differs: when neither supplies a type, ExactFilter binds
values as strings (preserving leading zeros such as 007), while
RangeFilter autodetects the type from the supplied value. This distinction
also applies when these filters are used as relation-path leaves.
Multiple values per filter (IN / NOT IN)
Every filter that compares a value accepts either a single value or a list —
the same filter declaration serves both. RangeFilter is the exception: its
value is an operator map (gte, lte, …), not a value to widen. No
configuration is needed on the resource side:
?filters[color_id]=1 → WHERE color_id = 1
?filters[color_id][]=1&filters[color_id][]=2 → WHERE color_id IN (1, 2)
This is what a facetted frontend filter (a multi-select of categories, say) sends, and it works on relation-path keys too — so a multi-select over a related record's column needs no custom filter class:
'filters' => [
'color_id' => ExactFilter::class, // ?filters[color_id][]=1&filters[color_id][]=2
'categories' => MmFilter::class, // ?filters[categories][]=5&filters[categories][]=9
'categories.title' => ExactFilter::class, // ?filters[categories.title][]=News&…[]=Press
],
Each filter widens in the way that matches its own comparison:
| Filter | List behaviour |
|---|---|
ExactFilter | column IN (v1, v2) |
PartialFilter / WordStartFilter | column LIKE p1 OR column LIKE p2 |
SearchFilter | every term is searched across every configured column, OR-ed together |
MmFilter | related to any of the values — or to all of them with
['match' => 'all'] |
| relation path (dotted key) | the declared leaf filter compares the list on the related table |
RangeFilter | not applicable — its value is an operator map (gte, lte, …) |
An empty list (?filters[color_id][]=) applies no constraint at all, so a
frontend that clears its facet does not have to drop the parameter. This also
switches off a non-private default for that request — public defaults are a
starting value, not a restriction (use private for those, see
Default values and private filters).
For ExactFilter without a separator, ?filters[title]= compares
against the empty string; ?filters[title][]= clears the filter. With a
separator, an empty scalar also clears the filter.
MmFilter compares record identifiers, so each of its values must be a
non-negative integer. A value that is not — foo, 1.9 — returns
400 Bad Request instead of being cast to a UID.
Negating a filter
The negate option flips a filter to its negative form. It is a server-side
decision read from the resource config — a client cannot invert a filter it was
given:
'filters' => [
// ?filters[color_id]=1 → WHERE color_id != 1
// ?filters[color_id][]=1&filters[color_id][]=2 → WHERE color_id NOT IN (1, 2)
'not_color' => [ExactFilter::class, ['negate' => true]],
// records that carry none of the requested categories
'without_categories' => [MmFilter::class, ['negate' => true]],
],
For LIKE filters the negation of "matches any of the values" is "matches none of
them", so a list produces NOT LIKE p1 AND NOT LIKE p2. On a relation path the
negation applies to the record as a whole (t.uid NOT IN (subquery)): an
article whose other category still matches is excluded, which is what
"articles without category News" means.
Direct negated comparisons (!=, NOT IN, NOT LIKE) exclude NULL
values under SQL's null semantics. A nullable column therefore need not produce
the complement of the positive filter. Relation-path negation instead tests
whether any related record matches, as described above.
Comma-separated values
Some clients cannot produce bracket arrays. Set separator to accept a list
in a single scalar parameter instead:
'filters' => [
'color_id' => [ExactFilter::class, ['separator' => ',']], // ?filters[color_id]=1,2
],
Values are trimmed and empty entries dropped. Without the option a comma is an ordinary character in the value.
Bounding the list length
A list is capped at 100 values to keep a crafted request from building an
unbounded IN list. Exceeding the cap returns 400 Bad Request naming the
filter and the limit, rather than truncating silently. Override it per filter
with maxValues (0 disables the cap):
'filters' => [
'color_id' => [ExactFilter::class, ['maxValues' => 10]],
],
Duplicate values are collapsed before the cap is checked.
Configuration examples
Basic filters
use MaikSchneider\TcaApi\Filter\ExactFilter;
use MaikSchneider\TcaApi\Filter\PartialFilter;
use MaikSchneider\TcaApi\Filter\WordStartFilter;
'filters' => [
'title' => ExactFilter::class, // ?filters[title]=Foo
'name' => PartialFilter::class, // ?filters[name]=oo → LIKE %oo%
'slug' => WordStartFilter::class, // ?filters[slug]=Fo → LIKE Fo%
],
Many-to-many filter
For MmFilter, if the options array is omitted the extension derives the MM
config from TCA automatically (requires a valid MM key on the field):
use MaikSchneider\TcaApi\Filter\MmFilter;
'filters' => [
// Shorthand: derive MM config from TCA automatically
'categories' => MmFilter::class,
// Require a record to carry *every* requested category
// (?filters[topics][]=5&filters[topics][]=9), not just one of them
'topics' => [MmFilter::class, ['match' => 'all']],
// Options form: supply MM table config explicitly
'tags' => [
MmFilter::class,
[
'mm_table' => 'tx_myext_article_tag_mm',
'mm_local_key' => 'uid_local',
'mm_foreign_key' => 'uid_foreign',
],
],
],
Relation-path filters
A dotted filter key filters the resource by a column reached through one or
more relations. The last path segment is a scalar column on the deepest related
table; the segments before it are relations to traverse. The declared filter
(ExactFilter below) performs the comparison on that column — the dot in the
key is detected automatically, so there is no extra filter class to register:
use MaikSchneider\TcaApi\Filter\ExactFilter;
'filters' => [
'color_id.name' => ExactFilter::class, // one FK hop → the colour's name
'categories.title' => ExactFilter::class, // one MM hop → a category's title
'related_items.name' => ExactFilter::class, // one inline hop → an inline child's name
'categories.parent.title' => ExactFilter::class, // two hops → a category's parent's title
],
Usage (dotted keys require the bracket form — see the note below):
?filters[color_id.name]=Red
?filters[categories.title]=News
?filters[categories.parent.title]=Root
Any comparison filter can be the leaf. ExactFilter is the default;
RangeFilter, WordStartFilter and PartialFilter compare the leaf
column directly, and their options are forwarded through the options form:
'filters' => [
'stock.updated_at' => [RangeFilter::class, ['type' => 'date']],
],
How it works
Each relation hop wraps the previous result in an IN (subquery) that maps
related UIDs back to the holder record, built inside-out and de-duplicating, so
pagination and counts stay correct. Every hop is built through the native query
builder, so each intermediate table's enable-field restrictions (deleted,
hidden / disabled, start/end time, fe_group) are applied at every
level — a hidden or deleted intermediate record never leaks a match.
Supported relations and limits
- Supported: single-value
selectforeign-key relations; MM relations (includingtype=categoryandtype=groupwithMM); andtype=inlinerelations (foreign_field, honouring theforeign_table_fieldandforeign_match_fieldsdiscriminators). - Not supported: non-MM group relations (comma-separated storage — add
MMto the field instead), and MM/group relations that allow more than one table (the target table would be ambiguous). - Maximum of 3 relation hops per path.
- Invalid paths — unknown column, unsupported or ambiguous relation, or too many
hops — are rejected at boot with a clear
InvalidApiDefinitionException, so the misconfiguration surfaces immediately rather than on the first request that uses the filter.
Note
Relation-path filters must be supplied via the bracket form
(?filters[categories.title]=News). PHP rewrites dots in top-level
query-parameter names to underscores, so ?categories.title=News would
not match — dots inside filters[…] are preserved.
Search filter
The search filter allows searching across multiple columns simultaneously:
use MaikSchneider\TcaApi\Filter\SearchFilter;
'filters' => [
'q' => [
SearchFilter::class,
[
'columns' => ['title', 'teaser', 'body'],
'match' => 'partial', // 'partial' (default) or 'word_start'
],
],
],
Usage: ?filters[q]=typo3 — searches across all configured columns with
WHERE (title LIKE '%typo3%' OR teaser LIKE '%typo3%' OR body LIKE '%typo3%').
Range filter
use MaikSchneider\TcaApi\Filter\RangeFilter;
'filters' => [
'year' => RangeFilter::class,
],
Usage: ?filters[year][gte]=2020&filters[year][lte]=2024
The bound DBAL parameter type is resolved in this order:
- The explicit
typefilter option (escape hatch — see below). -
The TCA configuration of the column:
type: number(integer format) →inttype: number, format: decimal→floattype: datetimewithoutdbType(UNIX timestamp column) →inttype: datetimewithdbType(nativeDATE/DATETIME/TIME) →stringtype: input, eval: …,int,…→int
- Autodetection from the request value (integers stay integers, decimal / numeric strings are bound as strings, non-numeric strings such as ISO dates are bound as strings).
Use the type option to override TCA-inferred and autodetected types — for
example to keep digit-only strings (zero-padded SKU codes) intact, or to
force a specific cast on a column whose TCA type does not map cleanly:
'filters' => [
'created_at' => [RangeFilter::class, ['type' => 'date']], // ?filters[created_at][gte]=2024-01-01
'price' => [RangeFilter::class, ['type' => 'float']], // ?filters[price][lte]=99.99
'sku' => [RangeFilter::class, ['type' => 'string']], // preserves leading zeros
],
Supported type values: int, float, string, date,
datetime (date and datetime are aliases of string).
Custom filters
Implement FilterInterface to create your own filter strategy. The extension
discovers all implementations automatically via Symfony DI — no
Services.yaml registration is needed.
use MaikSchneider\TcaApi\Filter\FilterContext;
use MaikSchneider\TcaApi\Filter\FilterInterface;
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
final class PublishedAfterFilter implements FilterInterface
{
public function apply(QueryBuilder $qb, FilterContext $context): void
{
$qb->andWhere($qb->expr()->gte(
$context->column,
$qb->createNamedParameter((int)$context->value),
));
}
}
Note
DataRepository queries the main resource table under the alias t
(FROM {table} AS t, SELECT t.*). When your filter joins an
additional table, qualify any main-table column references with t. to
avoid ambiguous-column SQL errors.
Joining an additional table
Use $qb->join() to add a JOIN inside apply(). Reference the main
table via its alias t:
use MaikSchneider\TcaApi\Filter\FilterContext;
use MaikSchneider\TcaApi\Filter\FilterInterface;
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
final class TagFilter implements FilterInterface
{
public function apply(QueryBuilder $qb, FilterContext $context): void
{
$qb->join(
't',
'tx_myext_domain_model_tag',
'tag',
$qb->expr()->eq('t.tag_id', $qb->quoteIdentifier('tag.uid')),
)
->andWhere($qb->expr()->eq(
'tag.name',
$qb->createNamedParameter($context->value),
));
}
}
The first argument of join() must be 't' — the alias under which
DataRepository registered the resource table. Column references on the
joined table (tag.name above) need no alias prefix.
FilterContext is a typed readonly value object:
| Property | Type | Description |
|---|---|---|
value | mixed | Filter value from the request query string |
table | string | Resource table name |
column | string | Column name this filter is applied to |
options | array | Filter-specific options from the resource config |
request | ServerRequestInterface|null | PSR-7 request — available in HTTP context; null in unit tests |
resourceConfig | ApiDefinition|null | Full resource config — available in HTTP context; null in unit tests |
Use $context->option('key', $default) to read from options with a
fallback default.
Register it the same way as built-in filters:
'filters' => [
'myColumn' => MyCustomFilter::class,
// or with options — accessed via $context->option('key')
'other' => [MyCustomFilter::class, ['key' => 'value']],
],
Default values and private filters
Two meta-keys are available on any filter definition and control server-side defaults and enforcement:
| Option | Type | Description |
|---|---|---|
default | mixed | Value applied when the filter is absent from the request URL params. |
private | bool | When true, default always applies — user-supplied values are
ignored. The filter is also excluded from the OpenAPI spec. |
use MaikSchneider\TcaApi\Filter\ExactFilter;
'filters' => [
// Overrideable default — applied when ?filters[color_id] is absent
'color_id' => [ExactFilter::class, ['default' => '1']],
// Private filter — default always applies, cannot be overridden via
// URL, and does not appear in the OpenAPI spec
'deleted' => [ExactFilter::class, ['default' => '0', 'private' => true]],
],
A private filter without a default has no effect.
A non-private default is a starting value, not an access restriction: the
client can send any other value for that filter, and an empty list
(?filters[color_id][]=) switches the filter off entirely for that request.
Anything the client must not be able to change belongs behind private.
Boot-time pre-resolution (FilterPreResolvableInterface)
For filters that need expensive configuration — such as TCA schema lookups —
implement FilterPreResolvableInterface in addition to FilterInterface:
use MaikSchneider\TcaApi\Filter\FilterContext;
use MaikSchneider\TcaApi\Filter\FilterDefinition;
use MaikSchneider\TcaApi\Filter\FilterInterface;
use MaikSchneider\TcaApi\Filter\FilterPreResolvableInterface;
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
final class MyExpensiveFilter implements FilterInterface, FilterPreResolvableInterface
{
public function preResolve(FilterDefinition $definition): FilterDefinition
{
// Called once at definition build time (cache miss).
// Derive expensive config and bake it in via withOptions().
if ($definition->table === '') {
return $definition; // guard for unit-test contexts
}
return $definition->withOptions(['resolved_value' => $this->deriveFromTca($definition)]);
}
public function apply(QueryBuilder $qb, FilterContext $context): void
{
// $context->option('resolved_value') is already set from preResolve()
$qb->andWhere($qb->expr()->eq(
$context->column,
$qb->createNamedParameter($context->option('resolved_value')),
));
}
}
ApiDefinitionLoader calls preResolve() once per filter column during the
definition build (on cache miss). The returned FilterDefinition, with derived
options merged in, is stored alongside the ApiDefinition cache entry.
Subsequent boots load the pre-resolved definition directly — the TCA lookup does
not repeat.
apply() must remain safe when preResolve() was never called (unit-test
contexts where no loader is involved). Check $definition->table === '' or
$context->option('resolved_value') === null as guards.