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)
Copied!

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
],
Copied!

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]],
],
Copied!

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
],
Copied!

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]],
],
Copied!

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%
],
Copied!

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',
        ],
    ],
],
Copied!

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
],
Copied!

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
Copied!

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']],
],
Copied!

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 select foreign-key relations; MM relations (including type=category and type=group with MM); and type=inline relations (foreign_field, honouring the foreign_table_field and foreign_match_fields discriminators).
  • Not supported: non-MM group relations (comma-separated storage — add MM to 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.

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'
        ],
    ],
],
Copied!

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,
],
Copied!

Usage: ?filters[year][gte]=2020&filters[year][lte]=2024

The bound DBAL parameter type is resolved in this order:

  1. The explicit type filter option (escape hatch — see below).
  2. The TCA configuration of the column:

    • type: number (integer format) → int
    • type: number, format: decimalfloat
    • type: datetime without dbType (UNIX timestamp column) → int
    • type: datetime with dbType (native DATE/DATETIME/TIME) → string
    • type: input, eval: …,int,…int
  3. 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
],
Copied!

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),
        ));
    }
}
Copied!

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),
            ));
    }
}
Copied!

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']],
],
Copied!

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]],
],
Copied!

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')),
        ));
    }
}
Copied!

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.