TCA_API 

Extension key

tca_api

Package name

maikschneider/tca-api

Version

0.1

Language

en

Author

Maik Schneider & Contributors

License

This document is published under the Open Publication License.

Rendered

Fri, 29 May 2026 14:29:23 +0000


TCA_API is a TYPO3 extension that exposes database tables as Hydra JSON-LD resources through a configuration-driven REST API. Define which tables, columns, and operations to expose — the extension handles routing, serialization, validation, pagination, and access control.


Introduction 

What is TCA_API, what are its features, and what does it do?

Installation 

How to install and set up the extension via Composer and TYPO3 site sets.

Configuration 

Complete reference for resource definitions, columns, filters, sorting, security, and site settings.

API Usage 

How to use the REST endpoints, pagination, filtering, and the OpenAPI spec and Swagger UI.

Developer Guide 

PSR-14 events, custom operation handlers, request attributes, and extensibility.

Known Problems 

Current limitations, known issues, and planned features.

Introduction 

What does it do? 

TCA_API is a TYPO3 extension that automatically generates a REST API from your TYPO3 TCA (Table Configuration Array) definitions. It exposes database tables as Hydra JSON-LD resources through a configuration-driven approach — no custom controllers or Extbase models needed.

By placing a simple PHP configuration file in your extension's Configuration/TcaApi/ directory, you get a fully functional REST API with CRUD operations, filtering, sorting, pagination, validation, and access control.

Motivation 

TYPO3 offers several existing approaches for serving content as structured data. TCA_API was built to fill a gap where other API extensions fall short: exposing multiple resources uniformly, with minimal boilerplate and strong query efficiency. See Motivation for the full comparison.

Features 

  • Full CRUD — List, show, create, update (PUT & PATCH), and delete operations
  • Hydra JSON-LD — Responses follow the Hydra specification (application/ld+json)
  • Configuration-driven — Expose tables by registering a PHP configuration array; no custom controllers needed
  • Serialization groups — Use groups to control which columns appear per operation (list, show, create, update)
  • Filtering — Exact, partial, word-start, range, full-text search, and many-to-many filter strategies via query parameters; extensible via FilterInterface
  • Sorting — Configurable allowed sort columns with defaults
  • Pagination — Offset-based pagination with Hydra PartialCollectionView links
  • Validation — Required, maxLength, minLength, and regex validators with structured 422 error responses
  • Access control — Per-operation roles: PUBLIC, FE_USER, FE_GROUP, BE_USER, BE_ADMIN, OWNER (record-level ownership), or custom callable voters
  • Virtual properties — Computed fields via callables or column processors, with support for referencing existing columns (including file/image columns at different sizes)
  • Relation handling — Shallow stubs or fully embedded related records (configurable depth)
  • Userinfo endpoint — Expose the authenticated FE user's own record at a configurable URL
  • OpenAPI + Swagger UI — Auto-generated OpenAPI 3.0 spec and interactive Swagger UI served directly from the API prefix
  • PSR-14 events — Hook into the request lifecycle with Before/AfterOperation and Before/AfterWrite events
  • TYPO3 DataHandler — Write operations use TYPO3's DataHandler for safe, consistent data manipulation
  • Extensible handler pipeline — Register custom operation handlers or override built-in ones from any extension

Current state 

Motivation 

Several existing approaches exist for serving TYPO3 content as structured data — Extbase repositories, the Record API, EXT:headless, and annotation-driven frameworks like EXT:t3api and EXT:nnrestapi. TCA_API was built because none of them solve the read-heavy API use case without significant trade-offs in performance, boilerplate, or flexibility.

Why existing approaches fall short 

Why not EXT:nnrestapi? 

EXT:nnrestapi is an endpoint framework: you write a PHP class extending AbstractApi, annotate its methods, and return data however you choose. This gives maximum flexibility, but shifts all responsibility to the developer:

  • No built-in relation loading. Returning Extbase domain objects hands serialization to TYPO3's standard DataMapper, which resolves every relation property individually during JSON conversion. A 20-item collection with 2 relation types produces the same 41 queries as any Extbase-based approach.
  • No built-in filtering, pagination, or validation. Each endpoint is custom PHP. Adding a filter means writing a query constraint by hand; pagination requires manually counting rows and slicing results. Every resource needs its own implementation.
  • No configuration model. There is no declarative description of a resource's shape, access rules, or allowed operations. Everything is imperative code inside action methods, growing linearly with the number of resources.
  • Plain JSON output. Responses are plain JSON — no Hydra JSON-LD, no @context, no @type, no discoverable collection links.

nnrestapi is a reasonable choice for bespoke, one-off endpoints where the flexibility is genuinely needed. It is a poor fit for exposing multiple resources uniformly.

Why not EXT:t3api? 

EXT:t3api is the closest prior art — Hydra JSON-LD output, built-in filtering, pagination, serialization groups, and an API-Platform-inspired annotation model. It is a mature extension. The core limitation is the persistence layer:

  • Built on Extbase DataMapper. Resources must be Extbase domain models (AbstractDomainObject subclasses). Queries use PersistenceManagerInterface::createQueryForType(), and all relation properties are resolved via Extbase's DataMapper.
  • N+1 queries for embedded relations. DataMapper resolves relations through LazyLoadingProxy objects fetched on first access. Serializing 20 articles with 2 embedded relation types fires 41 database queries — one for the collection plus one per relation per row.
  • Extbase model required per resource. Exposing a table from a third-party extension that ships no domain model class requires creating one.

Why not the TYPO3 Record API? 

The Record API introduced in TYPO3 v13 provides RecordFactory and typed Record objects with lazy relation resolution. It is a solid foundation for Fluid templates, but has key limitations for API use:

  • Per-record hydration overhead. RecordFactory::createResolvedRecordFromDatabaseRow() instantiates a Record object per row, transforms each field through RecordFieldTransformer, and wraps relations in LazyRecordCollection or RecordPropertyClosure closures. For a collection of 20 records with 5 relation columns, this creates 20 Record objects + 100 lazy wrappers — before any relation is even accessed.
  • No batch relation loading. When serializing a collection to JSON, every lazy relation fires a separate query on first access. 20 articles × (1 color + 1 category MM) = 41 queries. The GreedyDatabaseBackend mitigates this by pre-fetching an entire foreign table by PID, but this over-fetches and only helps within a single page context.
  • Designed for rendering, not serialization. Calling $record->toArray() force-instantiates all lazy closures. There is no depth control, no cycle detection, and no way to configure which relations to embed vs. return as references.

Why not Extbase alone? 

Extbase's DataMapper suffers from the classic N+1 query problem. Each relation property on each domain object triggers a separate getPreparedQuery() call. The @Lazy annotation defers queries but doesn't batch them.

Why not EXT:headless? 

EXT:headless replaces TYPO3's HTML output with JSON via TypoScript JSON content objects. It uses the same rendering pipeline (CONTENT cObjects, DataProcessors) and executes the same queries as a normal page render. The benefit is smaller payloads for the frontend, not fewer database queries.

How TCA_API solves this 

TCA_API takes a fundamentally different approach: raw SQL via QueryBuilder with bulk preloading and a zero-boilerplate configuration model.

  1. No ORM, no object hydration. Records are raw associative arrays from ConnectionPool::getQueryBuilderForTable(). Zero overhead from property mapping, proxy objects, or lazy wrappers.
  2. EmbedPreloader eliminates N+1 queries. Before serialization, the preloader scans all rows in a collection, collects every referenced foreign key, and executes one query per relation type — regardless of collection size:

    • hasOne FKs: SELECT * FROM colors WHERE uid IN (1, 2, 3)
    • hasMany MM: SELECT f.*, mm.uid_local FROM categories f JOIN mm ON ... WHERE mm.uid_foreign IN (...)
    • hasMany foreignField: SELECT * FROM children WHERE parent_id IN (...)
  3. Fixed query count. The number of queries is 1 + R (one collection query + one per relation type), not 1 + N×R. Adding more rows to a page does not increase the query count.
  4. Zero boilerplate per resource. A three-key PHP array is a complete resource definition — no domain model class, no repository, no controller, no routing config. Filtering, pagination, access control, and validation are declared in the same file.

Query count analysis 

The query-count difference between strategies is deterministic and can be derived from simple formulas:

  • Naive N+1 / Extbase / t3api: Q = 1 + N × R
  • TCA_API (EmbedPreloader): Q = 1 + R

Where N is the collection size and R is the number of embedded relation types per record.

Collection size Relations N+1 queries TCA_API queries Savings
20 items 2 41 3 92.7%
50 items 2 101 3 97.0%
100 items 3 301 4 98.7%
100 items 5 501 6 98.8%

These numbers are theoretical projections from the formula, not measurements from live extensions. Wall-clock impact depends on database round-trip latency, query complexity, and caching — all of which vary significantly between projects.

Comparison matrix 

Concern TCA_API EXT:t3api EXT:nnrestapi Record API EXT:headless
Query strategy Bulk preload Extbase N+1 Extbase N+1 or manual Lazy + greedy-by-PID Same as page render
Queries (20 × 2 rels) 3  41  41 (or raw, no rels)  41 (¹)  40-80 (²)
Object overhead None (raw arrays) Domain objects + proxies Domain objects or arrays Record + closures TypoScript cObjects
Configuration model PHP array (zero code) Annotations on model Per-method PHP N/A TypoScript
Filtering + pagination Built-in Built-in Manual per endpoint N/A N/A
JSON output format Hydra JSON-LD Hydra JSON-LD Plain JSON Manual Native JSON
Extbase model required No Yes Optional No No
Write operations DataHandler Repository Manual N/A N/A
Footnotes

(¹) The Record API's GreedyDatabaseBackend can reduce query count when related records share the same PID, so actual numbers may be lower in single-page contexts.

(²) EXT:headless query counts vary widely depending on TypoScript setup and DataProcessor configuration; the range reflects typical page-render workloads.

Call for feedback 

The comparison above is based on source-code reading and architectural reasoning. It has not been independently verified by developers experienced with EXT:t3api or EXT:nnrestapi, nor has it been validated against real-world production workloads.

If you work with these extensions and find that any claim is inaccurate, misleading, or missing important nuance — please open a GitHub Discussion or a pull request. Corrections are welcome, and the goal is an honest comparison rather than marketing copy.

Installation 

Requirements 

Dependency Version
PHP ^8.2
TYPO3 ^13.4 || ^14.3

Composer installation 

The extension is installed via Composer:

composer require maikschneider/tca-api
Copied!

Site set setup 

TCA_API ships a TYPO3 site set (maikschneider/tca-api). Add it to your site configuration to activate the API.

In the TYPO3 backend, go to Site Management → Sites and add maikschneider/tca-api as a dependency, or edit your config/sites/<site>/config.yaml directly:

dependencies:
  - maikschneider/tca-api
Copied!

Once the site set is active, the API is enabled and responds at the configured URL prefix (/_api/ by default). Site-level settings such as the API prefix, pagination defaults, CORS headers, and OpenAPI access can be customised in the TYPO3 backend under Site Management → Sites → Settings.

See Site Settings for all available options.

Site Settings 

The following settings are configurable per site in the TYPO3 backend under Site Management → Sites → Settings in the TCA_API category.

General 

tca_api.enabled

tca_api.enabled
Type
bool
Default
true

Enable or disable the API for this site. When disabled, the middleware passes all requests through without processing.

tca_api.apiPrefix

tca_api.apiPrefix
Type
string
Default
/_api/

URL path prefix for all API endpoints. Must start and end with a slash. The API is inactive until this is set.

tca_api.defaultItemsPerPage

tca_api.defaultItemsPerPage
Type
int
Default
20

Default number of items returned per page in collection responses. Can be overridden per resource in the resource definition.

tca_api.allowedResources

tca_api.allowedResources
Type
string
Default
(empty — all)

Comma-separated list of resource names to expose on this site. Leave empty to allow all registered resources.

tca_api.debugMode

tca_api.debugMode
Type
bool
Default
false

Return verbose error details in API responses. Disable on production sites.

API specification 

tca_api.openApiExposed

tca_api.openApiExposed
Type
string
Default
PUBLIC

Who may access the OpenAPI JSON spec at {apiPrefix}openapi.json. Allowed values: PUBLIC, FE_USER, BE_USER, BE_ADMIN, NONE.

tca_api.apiSpecTitle

tca_api.apiSpecTitle
Type
string
Default
TCA_API

Title shown in the OpenAPI spec info block and Swagger UI header.

tca_api.apiSpecDescription

tca_api.apiSpecDescription
Type
string
Default
(empty)

Short description shown in the OpenAPI spec info block and Swagger UI.

tca_api.apiSpecVersion

tca_api.apiSpecVersion
Type
string
Default
1.0.0

Version string for the OpenAPI spec info block.

tca_api.swaggerUiEnabled

tca_api.swaggerUiEnabled
Type
string
Default
PUBLIC

Who may access the interactive Swagger UI at {apiPrefix}swagger-ui. Allowed values: PUBLIC, FE_USER, BE_USER, BE_ADMIN, NONE.

CORS 

tca_api.corsEnabled

tca_api.corsEnabled
Type
bool
Default
false

Add CORS headers to API responses.

tca_api.corsOrigin

tca_api.corsOrigin
Type
string
Default
*

Value for the Access-Control-Allow-Origin header. Use * to allow all origins.

tca_api.corsAllowCredentials

tca_api.corsAllowCredentials
Type
bool
Default
false

When enabled, adds Access-Control-Allow-Credentials: true to CORS responses. Required when the frontend sends cookies or Authorization headers with cross-origin requests. Note: browsers reject credentialed requests when corsOrigin is * — set it to the specific origin instead.

Resource Definition 

Resources are defined as PHP files placed in any active extension's Configuration/TcaApi/ directory. No manual registration is needed — the extension auto-discovers all *.php files from every active package's Configuration/TcaApi/ directory at boot time and caches the result.

Each file returns a PHP array with the resource configuration.

To modify a resource defined by a third-party package, place an override file in Configuration/TcaApi/Overrides/ — see Overriding Third-Party Resource Configs.

General section 

The general key defines the basic resource properties:

return [
    'general' => [
        'table'        => 'tx_myext_domain_model_article',
        'resourceName' => 'articles',
        'resourceType' => 'Article',
        // operations defaults to ['list', 'show'] — omit for read-only resources
        'itemsPerPage' => 20,
        'storagePid'   => 1,
    ],
];
Copied!
Key Description
table TYPO3 database table name.
resourceName URL slug used in /_api/{resourceName}.
resourceType JSON-LD @type value.
type Set to 'userinfo' to create a userinfo endpoint.
operations Array of enabled operations: list, show, create, update, delete. Defaults to ['list', 'show'] when omitted.
itemsPerPage Default page size for list operations (overrides the global site setting).
maxItemsPerPage Upper limit for itemsPerPage; when set, the requested page size is clamped to this value. No limit when omitted.
storagePid Page ID for newly created records.
writeMode Write execution strategy. acting_user (default) — DataHandler runs under the authenticated user identity, preserving the actor for audit purposes. system_admin — uses a synthetic backend-admin context, bypassing all TYPO3 ACL boundaries. Only enable for fully trusted, internal-only APIs.

Write mode 

By default, write operations (create, update, delete) run under the acting-user context — TYPO3's DataHandler preserves the caller's identity for audit purposes. Set writeMode to system_admin to bypass TYPO3 ACL checks entirely. Only use this for trusted, back-channel APIs.

'general' => [
    'table'        => 'tx_myext_domain_model_article',
    'resourceName' => 'articles',
    'resourceType' => 'Article',
    'operations'   => ['create', 'update', 'delete'],
    'writeMode'    => 'system_admin',   // default: 'acting_user'
],
Copied!

Minimal example (zero-config) 

Three keys are all that is needed for a public read-only resource. operations defaults to ['list', 'show'] and both are PUBLIC by default:

return [
    'general' => [
        'table'        => 'tx_myext_domain_model_article',
        'resourceName' => 'articles',
        'resourceType' => 'Article',
    ],
];
Copied!

To enable write operations, add them explicitly and configure security:

use MaikSchneider\TcaApi\Enum\AccessRole;

return [
    'general' => [
        'table'        => 'tx_myext_domain_model_article',
        'resourceName' => 'articles',
        'resourceType' => 'Article',
        'operations'   => ['list', 'show', 'create', 'update', 'delete'],
        'storagePid'   => 1,
    ],
    'security' => [
        'create' => AccessRole::FE_USER,
        'update' => AccessRole::FE_USER,
        'delete' => AccessRole::BE_ADMIN,
    ],
];
Copied!

Full example 

use MaikSchneider\TcaApi\Enum\AccessRole;
use MaikSchneider\TcaApi\Filter\ExactFilter;
use MaikSchneider\TcaApi\Filter\MmFilter;
use MaikSchneider\TcaApi\Serializer\FileProcessing\FileProcessor;
use MaikSchneider\TcaApi\Serializer\Processing\TypoLinkProcessor;

return [
    'general' => [
        'table'        => 'tx_myext_domain_model_article',
        'resourceName' => 'articles',
        'resourceType' => 'Article',
        'operations'   => ['list', 'show', 'create', 'update', 'delete'],
    ],
    'columns' => [
        'title' => [
            'type'       => 'string',
            'groups'     => ['list', 'show', 'create', 'update'],
            'required'   => true,
            'validators' => [
                ['type' => 'maxLength', 'max' => 20],
                ['type' => 'minLength', 'min' => 3],
                ['type' => 'regex', 'pattern' => '/^[\w\s]+$/u'],
            ],
        ],
        'color_id'   => ['groups' => ['list', 'show', 'create', 'update']],
        'categories' => ['groups' => ['list', 'show', 'create', 'update']],
        'profile_photo' => ['groups' => ['list', 'show']],
        'downloads' => [
            'groups'    => ['list', 'show'],
            'processor' => FileProcessor::class,
        ],
        'article_url' => [
            'groups'    => ['list', 'show'],
            'processor' => TypoLinkProcessor::class,
        ],
    ],
    'filters' => [
        'title'      => ExactFilter::class,
        'color_id'   => ExactFilter::class,
        'categories' => MmFilter::class,
    ],
    'order' => [
        'allowed' => ['title', 'uid'],
        'default' => ['uid' => 'asc'],
    ],
    'security' => [
        'list'   => AccessRole::PUBLIC,
        'show'   => AccessRole::PUBLIC,
        'create' => AccessRole::FE_USER,
        'update' => AccessRole::FE_USER,
        'delete' => AccessRole::BE_ADMIN,
    ],
];
Copied!

Overriding Third-Party Resource Configs 

TCA_API mirrors TYPO3's $GLOBALS['TCA'] + TCA/Overrides/ loading pattern. Any active extension can modify a resource configuration that was defined by another extension.

How it works 

At boot time the loader runs two passes over every active package:

  1. Base pass — loads every Configuration/TcaApi/*.php file and writes the result into $GLOBALS['TCA_API'] keyed by resourceName. When two packages define the same resourceName, the last package to load wins (TYPO3 package load order).
  2. Override pass — requires every Configuration/TcaApi/Overrides/*.php file (depth 0, sorted alphabetically within each package). Override files are plain PHP side-effects on $GLOBALS['TCA_API'] — no return value is expected.

After both passes the global is parsed into typed ApiDefinition DTOs and cached. At runtime, use ApiRegistry::get() to read definitions; the global itself is not guaranteed to be set on cache hits.

Writing an override file 

Create a PHP file under Configuration/TcaApi/Overrides/ in any active extension. The filename is arbitrary.

Add a column

<?php

declare(strict_types=1);

// EXT:my_ext/Configuration/TcaApi/Overrides/ExtendArticles.php
$GLOBALS['TCA_API']['articles']['columns']['subtitle'] = [
    'groups' => ['list', 'show'],
];
Copied!

Remove a column

unset($GLOBALS['TCA_API']['articles']['columns']['internal_notes']);
Copied!

Restrict operations

$GLOBALS['TCA_API']['articles']['general']['operations'] = ['list', 'show'];
Copied!

Change a security role

use MaikSchneider\TcaApi\Enum\AccessRole;

$GLOBALS['TCA_API']['articles']['security']['delete'] = AccessRole::FE_USER;
Copied!

Add a filter

use MaikSchneider\TcaApi\Filter\ExactFilter;

$GLOBALS['TCA_API']['articles']['filters']['category_id'] = ExactFilter::class;
Copied!

Multiple overrides for the same resource are applied in alphabetical filename order within a package, and in TYPO3 package load order across packages. The last write always wins.

Explicit vs. implicit mode 

The visibility mode — implicit (all TCA columns exposed) vs. explicit (only columns with groups) — is re-evaluated after all overrides are applied.

  • Adding a column with a groups key to an otherwise implicit resource switches it to explicit mode for that resource.
  • Removing the only column that had groups switches it back to implicit.
  • Adding a column without groups to an explicit resource keeps it explicit; the new column is recorded in the DTO but excluded from all operation outputs.

Columns 

The columns section of a resource definition controls which database columns are exposed and how they behave. Each entry maps to a database column name.

Visibility modes 

TCA_API has two visibility modes. The mode is auto-detected per resource:

Default mode
Active when no column has groups set. All non-system TCA columns (i.e. not hidden, deleted, tstamp, crdate, language/workspace fields) are automatically exposed for read and write.
Explicit mode
Active as soon as any column declares groups. Only columns with a matching groups entry are exposed; all others are hidden.

Serialization groups 

Use groups to control visibility per operation:

'columns' => [
    'title'  => ['groups' => ['list', 'show', 'create', 'update']],  // everywhere
    'teaser' => ['groups' => ['list']],                              // list only
    'body'   => ['groups' => ['show']],                              // detail view only
    'secret' => ['groups' => []],                                    // never exposed
],
Copied!

Valid group names: list, show, create, update.

Column options reference 

All keys are optional.

Key Description
type Data type hint for OpenAPI schema (e.g. string, integer).
readable true — include in responses. Legacy option; use groups instead.
writable true — accept in create/update requests. Legacy option; use groups instead.
groups Array of operations where this column is active — triggers explicit mode (list, show, create, update).
required Require on POST/PUT (skipped on PATCH if absent).
embed true or ['depth' => N] — inline related records instead of shallow stubs. See Relations.
resourceName Override the related resource used for relation columns. Normally TCA API looks up the child resource by its DB foreign_table. Set this when multiple resources are registered for the same table, or to explicitly control which resource's security and column config applies to nested writes. See Relations for a full example.
processor Column processor class. Does not trigger explicit mode. See Column processors.
validators Array of validation rules. See Validation.
upload Enable file upload for this column via multipart/form-data requests. Must be an array with at least a folder key (FAL storage reference, e.g. 1:/uploads/). See File Uploads for all options.
image Image processing options for ImageProcessor columns. Controls dimensions, crop variant selection, format conversion, and URL mode. See Column processors for all options.

Field type support 

The serializer automatically handles all TYPO3 TCA field types. Relational types are resolved via dedicated serializers; scalar types that store encoded data are decoded before output; sensitive types are excluded entirely.

TCA type Handling Output
file FileFieldSerializer — auto-selects ImageProcessor or FileProcessor Processed file/image object(s)
category RelationSerializer Shallow stub or embedded record
select (relational) RelationSerializer Shallow stub or embedded record
inline RelationSerializer Shallow stub or embedded record
group GroupFieldSerializer Shallow stub or embedded record
json Auto-decoded via json_decode Decoded array/object
imageManipulation Auto-decoded via json_decode Decoded crop config object
flex Auto-decoded via GeneralUtility::xml2array Decoded associative array
datetime Auto-formatted to ISO 8601 (DateTimeInterface::ATOM) "2024-01-01T00:00:00+00:00" or null
link Auto-applies TypoLinkProcessor Resolved public URL string
password Excluded — never appears in API responses (column omitted)
input, text, number, email, color, country, slug, radio, select (static) Raw DB value String, integer, or appropriate scalar
check Raw DB value Bitmask integer
language Excluded by TcaColumnDiscovery via ctrl.languageField (column omitted)
folder, none, passthrough, user Raw DB value Implementation-defined

An explicit processor on a column definition always overrides the automatic handling described above.

Column processors 

Column processors transform values during serialization. The extension ships two built-in processors:

FileProcessor

Serialises file references (FAL). Useful for file download columns.

use MaikSchneider\TcaApi\Serializer\FileProcessing\FileProcessor;

'downloads' => [
    'groups'    => ['list', 'show'],
    'processor' => FileProcessor::class,
],
Copied!
TypoLinkProcessor

Resolves TYPO3 typolinks to full URLs.

use MaikSchneider\TcaApi\Serializer\Processing\TypoLinkProcessor;

'article_url' => [
    'groups'    => ['list', 'show'],
    'processor' => TypoLinkProcessor::class,
],
Copied!
ImageProcessor

Serialises image file references (FAL) with full crop-variant support and configurable processing options. By default the processor is used for every type=file TCA column that has no explicit processor key.

Options are controlled via the image sub-key on the column definition:

use MaikSchneider\TcaApi\Serializer\FileProcessing\ImageProcessor;

'hero_image' => [
    'groups'    => ['list', 'show'],
    'processor' => ImageProcessor::class,  // optional — also the default for type=file
    'image'     => [
        'maxWidth'      => 1200,
        'maxHeight'     => 800,
        'fileExtension' => 'webp',
        // omit cropVariant → all variants returned as a map
    ],
],
Copied!

Output mode 1 — all variants (cropVariant omitted or null):

Every crop variant stored on the file reference is processed and returned as a cropVariants map:

{
    "hero_image": {
        "publicUrl": "/fileadmin/hero.jpg",
        "mimeType": "image/jpeg",
        "fileSize": 204800,
        "metadata": {
            "title": "Hero",
            "alternative": "A hero image",
            "description": null,
            "copyright": "© 2026"
        },
        "cropVariants": {
            "default": {
                "publicUrl": "/fileadmin/_processed_/hero_c.webp",
                "width": 1024,
                "height": 512
            },
            "mobile": {
                "publicUrl": "/fileadmin/_processed_/hero_m.webp",
                "width": 375,
                "height": 200
            }
        }
    }
}
Copied!

Output mode 2 — single variant (cropVariant set to a variant ID):

Only that variant is processed and the result is inlined as the top-level image — no cropVariants key:

'hero_image' => [
    'groups' => ['list', 'show'],
    'image'  => [
        'maxWidth'    => 1200,
        'cropVariant' => 'default',    // single-variant mode
    ],
],
Copied!
{
    "hero_image": {
        "publicUrl": "/fileadmin/_processed_/hero_c.webp",
        "width": 1200,
        "height": 600,
        "mimeType": "image/jpeg",
        "fileSize": 204800,
        "metadata": { "title": "Hero", "alternative": null, "description": null, "copyright": null }
    }
}
Copied!
Key Type Description
width string Target width. Accepts a plain integer ("400"), crop-scale ("400c"), or scale-down-only ("400m"). Mutually usable with maxWidth.
height string Target height — same notation as width.
minWidth int Minimum width in pixels. Must be a positive integer.
minHeight int Minimum height in pixels. Must be a positive integer.
maxWidth int Maximum width in pixels. Must be a positive integer.
maxHeight int Maximum height in pixels. Must be a positive integer.
cropVariant string Crop variant identifier (e.g. 'default', 'mobile'). When set, only that variant is processed and the URL is returned as top-level publicUrl (no cropVariants key). When omitted, all variants are returned as a cropVariants map.
fileExtension string Target file extension for format conversion, e.g. 'webp'.
absolute bool Force an absolute URL. Default: false.

Custom processors must implement \MaikSchneider\TcaApi\Serializer\Processing\ColumnProcessorInterface.

Virtual Properties 

Virtual properties are computed fields appended to the serialized output. They appear after all real columns and can be driven by a callable or a column processor.

Callable 

A callable receives the already-serialized row and the raw DB record, and returns any serializable value:

'virtualProperties' => [
    'displayName' => [
        'callback' => [DisplayNameCallable::class, 'build'],
        'groups'   => ['list', 'show'],
    ],
],
Copied!

The callable signature is (array $serializedRow, array $rawRow): mixed. $serializedRow reflects columns already serialized in this request; $rawRow is the raw DB record.

Column processor 

A virtual property can also use a column processor:

'virtualProperties' => [
    'titleUppercase' => [
        'processor' => UppercaseProcessor::class,
        'groups'    => ['list', 'show'],
    ],
],
Copied!

The processor implements \MaikSchneider\TcaApi\Serializer\Processing\ColumnProcessorInterface. Without a column key the value passed to the processor is null.

Referencing an existing column 

Add a column key to source the virtual property's value from an existing DB column:

'virtualProperties' => [
    'titleCopy' => [
        'column'    => 'title',
        'processor' => MyProcessor::class,
        'groups'    => ['list', 'show'],
    ],
],
Copied!

The processor receives the column's raw DB value instead of null.

File / image column references 

For file/image columns the file references are fetched automatically and the result of the virtual property's own file processor is returned. This lets you expose the same image at different sizes per operation:

'virtualProperties' => [
    'profile_photo_thumb' => [
        'column'  => 'profile_photo',     // existing type=file column
        'image'   => [
            'maxWidth'    => 200,
            'maxHeight'   => 200,
            'cropVariant' => 'default',   // single variant → flat publicUrl/width/height
        ],
        'groups'  => ['list'],            // small thumb in list only
    ],
    'profile_photo_large' => [
        'column'  => 'profile_photo',
        'image'   => [
            'maxWidth'  => 1600,
            'maxHeight' => 1200,
            // no cropVariant → all variants returned as cropVariants map
        ],
        'groups'  => ['show'],            // full size in show only
    ],
],
Copied!

The virtual property uses its own image config — the referenced column's original config is ignored.

Visibility gate 

Virtual properties respect the same serialization groups as regular columns. When any column has a groups key (explicit mode), virtual properties without groups are excluded:

'virtualProperties' => [
    'displayName' => [
        'callback' => [DisplayNameCallable::class, 'build'],
        'groups'   => ['list', 'show'],  // required in explicit mode
    ],
    'adminNote' => [
        'callback' => [AdminNoteCallable::class, 'build'],
        'groups'   => ['show'],          // only in show, not list
    ],
],
Copied!

Virtual property options reference 

Key Description
callback Callable [ClassName::class, 'method'] that receives (array $serializedRow, array $rawRow) and returns any serializable value.
processor Column processor class implementing ColumnProcessorInterface.
column Name of an existing DB column to source the value from. When set, the processor receives the raw DB value instead of null. For file columns, file references are fetched automatically.
groups Array of operations where this virtual property is active (list, show). Required in explicit mode.
image Image processing options applied when the referenced column is a type=file column. Accepts the same keys as the column-level image config: width, height, minWidth, minHeight, maxWidth, maxHeight, cropVariant, fileExtension, absolute. See Column processors.

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
PartialFilter WHERE column LIKE %value%
WordStartFilter WHERE column LIKE value%
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)
MmFilter Subquery via MM intermediate table mm_table, mm_local_key, mm_foreign_key, mm_constraints (derived from TCA when omitted)

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,

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

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!

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.

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.

Sorting 

The order section configures which columns can be used for sorting and the default sort order.

Configuration 

'order' => [
    'allowed' => ['title', 'uid'],       // columns allowed for sorting
    'default' => ['uid' => 'asc'],       // fallback when no order is requested
],
Copied!
Key Description
allowed Array of column names that the client is permitted to sort by.
default Associative array of column => direction pairs used when the client does not provide an order parameter. Direction can be asc or desc.

Usage 

Sorting is controlled via the order query parameter:

GET /_api/articles?order[title]=asc
GET /_api/articles?order[uid]=desc
Copied!

Only columns listed in allowed are accepted. If the client requests an unsupported column, the default order is used instead.

Security 

The security section assigns an access role to each operation. Access is checked by the AccessController before the request reaches the operation handler.

Default access roles 

Operations not listed in security fall back to sensible defaults:

Operation Default Explanation
list PUBLIC Read operations are publicly accessible without authentication.
show PUBLIC Read operations are publicly accessible without authentication.
create DISABLED Write operations are disabled until explicitly configured.
update DISABLED Write operations are disabled until explicitly configured.
delete DISABLED Write operations are disabled until explicitly configured.

Access roles 

Role Description
AccessRole::PUBLIC No authentication required. Anyone can access the endpoint.
AccessRole::DISABLED Always denied regardless of authentication. Used as the implicit default for write operations that have no explicit security configuration.
AccessRole::FE_USER Requires a logged-in frontend user.
AccessRole::FE_GROUP Requires a frontend user belonging to a specific group. Use [AccessRole::FE_GROUP, [1,2]] to restrict to specific group IDs.
AccessRole::BE_USER Requires any authenticated backend user.
AccessRole::BE_ADMIN Requires an admin backend user.
AccessRole::OWNER Authenticated FE user whose UID matches the record's ownership column. See Ownership below.

Configuration 

use MaikSchneider\TcaApi\Enum\AccessRole;

'security' => [
    // list and show are PUBLIC by default — only specify to restrict them
    'create' => AccessRole::FE_USER,
    'update' => AccessRole::OWNER,      // Only the record owner may update
    'delete' => AccessRole::OWNER,
],
Copied!

Callable voters 

For custom access logic, use a callable instead of an AccessRole enum value. The callable receives the PSR-7 server request and an optional record array (for object-level security):

'security' => [
    'create' => [MyAccessChecker::class, 'checkCreatePermission'],
],
Copied!

The callable must return true to grant access or false to deny it.

class MyAccessChecker
{
    public static function checkCreatePermission(
        \Psr\Http\Message\ServerRequestInterface $request,
        ?array $record = null
    ): bool {
        // Custom access logic
        return true;
    }
}
Copied!

Ownership 

The ownership section enables declarative record-level security for write operations. It pairs with AccessRole::OWNER in the security config.

use MaikSchneider\TcaApi\Enum\AccessRole;

'security' => [
    'create' => AccessRole::FE_USER,
    'update' => AccessRole::OWNER,
    'delete' => AccessRole::OWNER,
],
'ownership' => [
    'column' => 'fe_user_id',   // DB column holding the owner's FE user UID
],
Copied!

What this does:

  • On create — the fe_user_id column is automatically set to the authenticated FE user's UID server-side. The client cannot supply this value; it is stripped from the request body regardless of the column's groups config.
  • On update/delete — the record's fe_user_id is compared to the current FE user's UID. If they don't match the request returns 403.

Separate tracking vs. auth columns 

Use setOnCreate when you want an additional tracking column alongside the auth column:

'ownership' => [
    'column'      => 'fe_user_id',      // column checked on update/delete (also written on create)
    'setOnCreate' => 'fe_creator_id',   // additional column written on create only
],
Copied!

On create, both column and setOnCreate receive the FE user UID. column must be populated for OWNER auth to work on subsequent update/delete; setOnCreate provides an immutable "created by" audit trail in a separate DB column.

BE_ADMIN bypass 

Backend admins bypass ownership checks by default. Set beAdminBypass: false to enforce ownership for admins too:

'ownership' => [
    'column'        => 'fe_user_id',
    'beAdminBypass' => false,           // default: true
],
Copied!

Ownership config reference 

Key Required Default Description
column When using OWNER DB column holding owner UID; compared on update/delete.
setOnCreate No Same as column Column auto-set on create (if different from column).
beAdminBypass No true When true, BE_ADMIN skips the ownership check.

Behaviour notes 

  • AccessRole::OWNER without ownership.column configured → 403 (fail-secure).
  • Unauthenticated request + OWNER403 (no FE user found).
  • setOnCreate without a logged-in FE user → column is not set (no injection if user is null).
  • Ownership columns are always stripped from client input regardless of groups config.
  • OWNER is only meaningful on update and delete; using it on list/show will always deny (no single record to compare against).

Table write restrictions 

The API enforces a built-in deny list for tables that must never be written through the API regardless of security configuration. Attempting to write to any of these tables returns 403 Forbidden:

  • be_users, be_groups, be_sessions
  • fe_sessions
  • sys_filemounts, sys_be_shortcuts, sys_action, sys_log

This protection is unconditional and cannot be overridden by configuration.

Denied access 

When access is denied, the API returns:

  • 401 Unauthorized — when no user is authenticated but one is required.
  • 403 Forbidden — when the authenticated user does not have sufficient privileges.

Relations 

Relations are resolved automatically from the TCA schema. By default, related records are serialized as plain IRI strings:

{
    "color_id": "/_api/colors/1",
    "categories": [
        "/_api/categories/5",
        "/_api/categories/8"
    ]
}
Copied!

This format is compatible with API Platform Admin and other Hydra clients that resolve IRIs on demand. To inline the full related record instead, see Embedding related records below.

Controlling recursion depth 

Control recursion depth with 'embed' => ['depth' => 2]. The default depth is 1.

'columns' => [
    'color_id' => ['embed' => ['depth' => 2]],
],
Copied!

Multi-table group fields 

TCA type=group fields with multiple allowed tables store relations in the prefixed tablename_uid format (e.g. pages_1,sys_file_3). These fields support embedding just like single-table relations:

// TCA definition
'related_items' => [
    'label' => 'Related Items',
    'config' => [
        'type' => 'group',
        'allowed' => 'tx_myext_domain_model_article,tx_myext_domain_model_color',
    ],
],

// API resource configuration
'columns' => [
    'related_items' => ['groups' => ['list', 'show'], 'embed' => true],
],
Copied!

The embedded response contains full objects from different tables, each with its own @type and fields:

{
    "related_items": [
        {
            "@id": "/_api/articles/201",
            "@type": "Article",
            "uid": 201,
            "title": "Related Article"
        },
        {
            "@id": "/_api/colors/1",
            "@type": "Color",
            "uid": 1,
            "name": "Red"
        }
    ]
}
Copied!

Each referenced table is resolved to its registered API resource. When no resource is registered for a table, a default-mode config is synthesized automatically (all TCA columns exposed).

Bulk preloading is fully supported: collection requests issue one SELECT per referenced table regardless of how many parent rows exist, avoiding N+1 queries.

Writing nested objects 

When a create or update request contains a related record as an object (rather than a UID), TCA_API creates the child record in-line. The child table must have a registered resource in the API registry.

{
    "title": "My Article",
    "color_id": { "name": "Red" }
}
Copied!

By default the child resource is looked up by the foreign table name from TCA. When multiple resources are registered for the same DB table, use the resourceName column option to pin the lookup to a specific resource:

'columns' => [
    'color_id' => [
        'groups'       => ['list', 'show', 'create', 'update'],
        'resourceName' => 'colors',   // look up "colors" resource, not by table
    ],
],
Copied!

This also controls which resource's security.create role is enforced on the nested write and which resource's column config is used for validation.

Supported relation types 

TCA type Default output Embedding
select / group UID list (1,2,3) — single table → IRI strings Yes
select / group Prefixed list (table_uid) — multi-table → IRI strings Yes
inline / select foreign_field back-reference → IRI strings Yes
Any + MM Intermediate MM table → IRI strings Yes
type=group + MM Column holds count, relations in MM → IRI strings Yes

Validation 

Configure validators per column to enforce data integrity on write operations.

Validator types 

Type Parameters Description
required (none) Enforced via the required column key. The field must be present and non-empty on POST/PUT. Skipped on PATCH if the field is absent.
maxLength max (int) Maximum string length.
minLength min (int) Minimum string length.
regex pattern (string) PCRE pattern the value must match.

Configuration 

'columns' => [
    'title' => [
        'groups'     => ['list', 'show', 'create', 'update'],
        'required'   => true,
        'validators' => [
            ['type' => 'maxLength', 'max' => 255],
            ['type' => 'minLength', 'min' => 3],
            ['type' => 'regex', 'pattern' => '/^[\w\s]+$/u'],
        ],
    ],
],
Copied!

Error response 

Validation failures return 422 Unprocessable Entity with a Hydra-compatible error response:

{
    "@context": "http://www.w3.org/ns/hydra/context.jsonld",
    "@type": "hydra:Error",
    "hydra:title": "Validation Failed",
    "hydra:description": "1 validation error(s)",
    "violations": [
        {
            "propertyPath": "title",
            "message": "Field 'title' is required.",
            "code": "REQUIRED"
        }
    ]
}
Copied!

PATCH behaviour 

On PATCH requests (partial updates), the required check is skipped for fields that are not present in the request body. Only the submitted fields are validated.

File Uploads 

TCA_API supports multipart file uploads on POST (create) and PUT/PATCH (update) endpoints. Adding an upload key to a column configuration enables the column to accept uploaded files via multipart/form-data requests.

How it works 

When a column has an upload key defined:

  1. The client sends the request as multipart/form-data instead of JSON.
  2. The file is validated against the constraints in upload and the allowed extensions from the TCA type=file column config.
  3. On success the file is stored in the configured FAL storage folder and a sys_file_reference record is created, linking the file to the parent record.
  4. On PATCH requests, existing file references are replaced when a new file is uploaded for that column. If the upload field is omitted entirely, existing references are preserved (PATCH semantics).

Configuration 

Add an upload key to any type=file column:

use MaikSchneider\TcaApi\Serializer\FileProcessing\FileProcessor;

'columns' => [
    'profile_photo' => [
        'groups' => ['list', 'show', 'create', 'update'],
        'upload' => [
            'folder'      => '1:/user_upload/',
            'maxSize'     => '5M',
            'duplication' => 'rename',
        ],
    ],
    'downloads' => [
        'groups'    => ['list', 'show', 'create', 'update'],
        'processor' => FileProcessor::class,
        'upload'    => [
            'folder'  => '1:/user_upload/',
            'maxSize' => '20M',
        ],
    ],
],
Copied!

Upload config reference 

Key Required Default Description
folder Yes FAL storage reference including path, e.g. 1:/uploads/. Must start with a storage UID followed by :/.
maxSize No unlimited Maximum allowed file size. Accepts an integer (bytes) or a human-readable string: 5M, 100K, 1G. Supports suffixes K/KB, M/MB, G/GB (case-insensitive).
duplication No rename

How to handle filename collisions in the target folder. One of:

  • rename — add a numeric suffix (default)
  • replace — overwrite the existing file
  • cancel — return an error when the filename already exists
filenameMask No (original filename)

Template string for the stored filename. Supports the following placeholders:

  • {name} — original filename without extension
  • {extension} — file extension without dot (e.g. jpg)
  • {ext} — file extension with dot (e.g. .jpg), empty if none
  • {contentHash} — MD5 hash of file content
  • {nameHash} — MD5 hash of the original filename (without extension)
  • {timestamp} — current Unix timestamp
  • {unique} — unique ID via TYPO3's StringUtility::getUniqueId()

Example: {timestamp}_{unique}{ext}1714000000_abc123.jpg

Allowed file extensions 

Allowed extensions are not configured in the upload key — they are read at validation time from the TCA type=file column's allowed config:

// EXT:myext/Configuration/TCA/tx_myext_domain_model_article.php
'profile_photo' => [
    'config' => [
        'type'    => 'file',
        'allowed' => 'jpg,jpeg,png,gif,webp',
    ],
],
Copied!

The special value common-image-types is resolved via $GLOBALS['TYPO3_CONF_VARS']['GFX']['imagefile_ext'].

When no allowed key is present, all file types are accepted.

Sending a file upload request 

Use Content-Type: multipart/form-data. All non-file fields are sent as regular form parts; file fields are sent as file parts.

Single-file upload (curl) 

curl -X POST https://example.com/_api/articles \
  -H "Cookie: fe_typo_user=..." \
  -F "title=My Article" \
  -F "profile_photo=@/path/to/photo.jpg"
Copied!

Update with partial PATCH 

# Replace only the file — title is unchanged
curl -X PATCH https://example.com/_api/articles/1 \
  -H "Cookie: fe_typo_user=..." \
  -F "profile_photo=@/path/to/new-photo.jpg"

# Update only the title — existing file reference is preserved
curl -X PATCH https://example.com/_api/articles/1 \
  -H "Cookie: fe_typo_user=..." \
  -F "title=Updated Title"
Copied!

Multiple files (single field) 

When the TCA column allows multiple files, pass the field multiple times:

curl -X POST https://example.com/_api/articles \
  -F "title=My Article" \
  -F "downloads[]=@/path/to/file1.pdf" \
  -F "downloads[]=@/path/to/file2.pdf"
Copied!

Validation errors 

Upload violations return 422 Unprocessable Entity in the same structured format as other validation errors:

{
    "@context": "http://www.w3.org/ns/hydra/context.jsonld",
    "@type": "hydra:Error",
    "hydra:title": "Validation Failed",
    "hydra:description": "2 validation error(s)",
    "violations": [
        {
            "propertyPath": "profile_photo",
            "message": "The file type is not allowed.",
            "code": "UPLOAD_MIME_TYPE"
        },
        {
            "propertyPath": "profile_photo",
            "message": "The file exceeds the maximum allowed size.",
            "code": "UPLOAD_MAX_SIZE"
        }
    ]
}
Copied!

Violation codes 

Code Meaning
UPLOAD_ERROR PHP file upload transport error (e.g. upload aborted, temp directory not writable). Reported error code from $_FILES.
UPLOAD_MIME_TYPE The detected MIME type is not permitted for this column.
UPLOAD_MAX_SIZE The file exceeds the maxSize limit configured in upload.
UPLOAD_EXTENSION The file extension is not in the list allowed by the TCA column.

OpenAPI / Swagger UI 

Upload columns are reflected in the auto-generated OpenAPI spec as binary fields in a multipart/form-data request body. The Swagger UI renders an interactive file-picker for every upload column, so you can test uploads directly from the browser.

Combining uploads with read processors 

A column can have both an upload key and a processor key. The upload key controls how incoming files are stored; the processor controls how the stored file references are serialized in read responses:

use MaikSchneider\TcaApi\Serializer\FileProcessing\FileProcessor;
use MaikSchneider\TcaApi\Serializer\FileProcessing\ImageProcessor;

'columns' => [
    // Accept uploads, serialize as full file metadata on read
    'downloads' => [
        'groups'    => ['list', 'show', 'create', 'update'],
        'processor' => FileProcessor::class,
        'upload'    => ['folder' => '1:/downloads/'],
    ],
    // Accept uploads, serialize with crop-variant data on read
    'hero_image' => [
        'groups'    => ['list', 'show', 'create', 'update'],
        'processor' => ImageProcessor::class,
        'upload'    => ['folder' => '1:/images/', 'maxSize' => '10M'],
    ],
],
Copied!

Caching 

TCA_API supports tag-based HTTP response caching for list and show operations. When enabled, responses are stored in a TYPO3 cache and served from there on subsequent identical requests — bypassing the database query and serialization entirely.

Configuration 

Add a cache section to any resource definition:

return [
    'general' => [
        'table'        => 'tx_myext_domain_model_article',
        'resourceName' => 'articles',
        'resourceType' => 'Article',
        'operations'   => ['list', 'show'],
    ],
    'cache' => [
        'enabled'            => true,
        'lifetime'           => 3600,          // seconds; default: 86400
        'parametersToIgnore' => ['preview'],   // bypass cache when ?preview=… present
    ],
];
Copied!
Key Required Default Description
enabled No false Enable caching for this resource.
lifetime No 86400 Cache TTL in seconds.
parametersToIgnore No [] Query parameters that bypass the cache entirely when present in the request (top-level or nested under filters[…]). Useful for preview or debug parameters.

Cache flow 

For every GET request to a cached resource the dispatcher:

  1. Builds a cache key from the operation, resource name, UID, and sorted query parameters.
  2. Checks the tca_api TYPO3 cache.
  3. HIT — returns the stored response body with X-TCA-API-Cache: HIT header.
  4. MISS — runs the handler, serializes records (tagging each one as {table}_{uid}), stores the response, and returns it with X-TCA-API-Cache: MISS and X-Cache-Tags headers.

Response headers 

Header Value
X-TCA-API-Cache HIT or MISS
X-Cache-Tags Space-separated list of tags for the records in this response, e.g. tx_myext_domain_model_article_1 tx_myext_domain_model_article_2

Cache invalidation 

Cache entries are invalidated automatically via a TYPO3 DataHandler hook (clearCachePostProc) whenever a record is created, updated, or deleted through the TYPO3 backend. All cached responses tagged with the affected table name are flushed.

Cache backend 

The extension registers the tca_api cache with an empty configuration, which defaults to TYPO3's Typo3DatabaseBackend (tables cf_tca_api / cf_tca_api_tags). For production use, configure a faster backend in config/system/settings.php (or LocalConfiguration.php):

$GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['tca_api'] = [
    'backend'  => \TYPO3\CMS\Core\Cache\Backend\FileBackend::class,
    'frontend' => \TYPO3\CMS\Core\Cache\Frontend\VariableFrontend::class,
    'options'  => ['defaultLifetime' => 86400],
];
Copied!

A Redis or Memcached backend can further reduce response times for high-traffic deployments.

Userinfo Endpoint 

A userinfo endpoint exposes the currently authenticated FE user's own record without requiring a UID in the URL. Set 'type' => 'userinfo' in the general section:

use MaikSchneider\TcaApi\Registry\ApiRegistry;

ApiRegistry::register('me', [
    'general' => [
        'type'         => 'userinfo',
        'table'        => 'fe_users',
        'resourceName' => 'me',
        'resourceType' => 'FeUser',
    ],
    'columns' => [
        'username'   => ['groups' => ['show']],
        'email'      => ['groups' => ['show']],
        'name'       => ['groups' => ['show']],
        'first_name' => ['groups' => ['show']],
        'last_name'  => ['groups' => ['show']],
    ],
]);
Copied!
GET /_api/me   → Returns the record of the logged-in FE user
Copied!

Behaviour 

  • Only GET is allowed — write operations are not supported on userinfo endpoints.
  • Returns 403 Forbidden if no FE user is authenticated.
  • All column features work as normal: embed, virtualProperties, column processors.
  • The security and operations keys are ignored — access is always tied to FE user authentication.

API Usage 

Once a resource is configured, it is automatically available under the API prefix (/_api/ by default).

CRUD endpoints 

All resources follow a standard REST pattern:

Method Endpoint Description
GET /_api/articles List collection.
GET /_api/articles/1 Show a single item.
POST /_api/articles Create a new item. Send a JSON body or multipart/form-data for file uploads.
PUT /_api/articles/1 Full update. All writable fields are expected. Use multipart/form-data when uploading files.
PATCH /_api/articles/1 Partial update. Only submitted fields are updated. Existing file references are preserved when the upload field is omitted.
DELETE /_api/articles/1 Delete an item.

Response format 

All responses use Hydra JSON-LD (application/ld+json).

Single item response 

{
    "@context": "http://www.w3.org/ns/hydra/context.jsonld",
    "@type": "Article",
    "@id": "/_api/articles/1",
    "uid": 1,
    "title": "Hello World"
}
Copied!

Collection response 

{
    "@context": "http://www.w3.org/ns/hydra/context.jsonld",
    "@type": "hydra:Collection",
    "hydra:totalItems": 42,
    "hydra:member": [
        {
            "@type": "Article",
            "@id": "/_api/articles/1",
            "uid": 1,
            "title": "Hello World"
        }
    ],
    "hydra:view": {
        "@type": "hydra:PartialCollectionView",
        "hydra:first": "/_api/articles?page=1",
        "hydra:last": "/_api/articles?page=3",
        "hydra:next": "/_api/articles?page=2"
    }
}
Copied!

Pagination 

Collections support offset-based pagination with Hydra PartialCollectionView links.

GET /_api/articles?page=2
GET /_api/articles?page=2&itemsPerPage=10
Copied!

The response includes navigation links in the hydra:view object (hydra:first, hydra:last, hydra:next, hydra:previous).

The default page size is controlled by:

  1. The resource's itemsPerPage setting (per-resource override).
  2. The site setting tca_api.defaultItemsPerPage (global default, 20).

Filtering 

Filter parameters can be passed as plain top-level query parameters:

GET /_api/articles?title=Hello
GET /_api/articles?color_id=1
GET /_api/articles?categories=5
Copied!

The legacy bracket notation is also accepted:

GET /_api/articles?filters[title]=Hello
GET /_api/articles?filters[price][gte]=10&filters[price][lte]=100
Copied!

When both styles are present for the same column, the bracket form wins. Only columns declared as filters in the resource configuration are matched; other top-level parameters are ignored.

See Filters for the full list of filter strategies and configuration.

Sorting 

Sorting is controlled via the order query parameter:

GET /_api/articles?order[title]=asc
GET /_api/articles?order[uid]=desc
Copied!

See Sorting for configuration details.

Sparse fieldsets 

Use the fields parameter to request only specific columns:

GET /_api/articles?fields[]=title&fields[]=uid
Copied!

Only the requested fields (plus @id, @type, and uid) are included in the response.

OpenAPI spec & Swagger UI 

The extension generates a live OpenAPI 3.0 JSON spec from the registered resources and exposes two additional endpoints:

Endpoint Description
{apiPrefix}openapi.json Machine-readable OpenAPI spec (e.g. /_api/openapi.json).
{apiPrefix}swagger-ui Interactive Swagger UI (e.g. /_api/swagger-ui).

Access to both endpoints is controlled by the tca_api.openApiExposed and tca_api.swaggerUiEnabled site settings respectively. Both default to PUBLIC.

API Platform Admin 

TCA_API is compatible with API Platform Admin out of the box. The extension emits the Hydra vocabulary that the admin's analyzer expects:

  • Entrypoint ({apiPrefix}/) lists all resources as Hydra links.
  • API documentation ({apiPrefix}/docs.jsonld) describes supported classes, properties, and operations in Hydra class format.
  • Collection responses include a hydra:search block with an IRI template that advertises filter parameters using plain field names (e.g. color_id, title), which the admin's analyzer maps directly to filter controls.
  • Relations are serialized as plain IRI strings, which API Platform Admin resolves automatically when navigating to a related record.

Point the admin at the entrypoint URL and it will discover resources, relations, and available filters without further configuration:

import { HydraAdmin } from "@api-platform/admin";

export default () => (
    <HydraAdmin entrypoint="https://example.com/_api/" />
);
Copied!

File uploads 

Columns configured with an upload key accept files via multipart/form-data on POST, PUT, and PATCH requests. See File Uploads for a complete guide including request examples, the upload config reference, validation error codes, and filename mask placeholders.

PSR-14 Events 

TCA_API dispatches PSR-14 events throughout the request lifecycle, allowing you to hook into and modify the API behaviour.

Available events 

Event Fired Use case
BeforeOperationEvent After access check, before handler Abort operation, inspect request.
AfterOperationEvent After handler, before response Add computed fields, transform response data.
BeforeWriteEvent Before DataHandler create/update/delete Validate or modify data before persistence.
AfterWriteEvent After DataHandler operations Trigger side effects (cache clear, logging).

All events are stoppable — call stopPropagation() to abort further processing.

Event API reference 

BeforeOperationEvent 

Method Description
getOperation(): string The resolved operation name (list, show, create, etc.).
getRequest(): ServerRequestInterface The PSR-7 server request (read-only — modification not supported here).
getConfig(): ApiDefinition The typed resource configuration.
stopPropagation(): void Aborts the operation. The dispatcher returns a 403 response.

AfterOperationEvent 

Method Description
getOperation(): string The operation that just completed.
getData(): array The serialized response data (collection array or single-item array).
setData(array $data): void Replace the response data. The dispatcher sends the modified array.
stopPropagation(): void Prevents subsequent listeners from receiving the event.

BeforeWriteEvent 

Method Description
getTable(): string The database table being written to.
getOperation(): string create, update, or delete.
getData(): array The data array about to be passed to DataHandler.
setData(array $data): void Replace the data before it is written.
stopPropagation(): void Aborts the write. The handler returns a 422 response.

AfterWriteEvent 

Method Description
getTable(): string The database table that was written.
getOperation(): string create, update, or delete.
getUid(): int The UID of the affected record (new record UID after create).
stopPropagation(): void Prevents subsequent listeners from receiving the event.

Registering a listener 

Register event listeners in your extension's Configuration/Services.yaml:

services:
  My\Extension\EventListener\RestrictArticleDeleteListener:
    tags:
      - name: event.listener
        identifier: 'my-extension/restrict-article-delete'
        event: MaikSchneider\TcaApi\Event\BeforeOperationEvent
Copied!

Example listener 

The following listener blocks delete requests on a specific table unless a custom request header is present. getConfig() returns an ApiDefinition object — access its properties directly.

namespace My\Extension\EventListener;

use MaikSchneider\TcaApi\Event\BeforeOperationEvent;

final class RestrictArticleDeleteListener
{
    public function __invoke(BeforeOperationEvent $event): void
    {
        if ($event->getConfig()->table !== 'tx_myext_domain_model_article') {
            return;
        }

        if ($event->getOperation() !== 'delete') {
            return;
        }

        if (!$event->getRequest()->hasHeader('X-Admin-Token')) {
            $event->stopPropagation(); // dispatcher returns 403
        }
    }
}
Copied!

Custom Operation Handlers 

The dispatcher routes each request through a handler pipeline — a prioritised list of objects that implement OperationHandlerInterface. Built-in handlers cover list, show, create, update, delete, and userinfo. Third-party extensions can add new operation types or replace built-in behaviour by registering their own handlers.

Interface 

namespace MaikSchneider\TcaApi\OperationHandler;

use MaikSchneider\TcaApi\Configuration\ApiDefinition;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

interface OperationHandlerInterface
{
    public function supports(
        ServerRequestInterface $request,
        string $operation,
        ApiDefinition $config
    ): bool;

    public function handle(
        ServerRequestInterface $request,
        ApiDefinition $config
    ): ResponseInterface;

    public function getPriority(): int;
}
Copied!
  • supports() — return true if this handler should process the request.
  • handle() — execute the operation and return a PSR-7 response.
  • getPriority() — higher values are checked first. Built-in handlers use priority 10.

Writing a custom handler 

namespace My\Extension\OperationHandler;

use MaikSchneider\TcaApi\Configuration\ApiDefinition;
use MaikSchneider\TcaApi\OperationHandler\OperationHandlerInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

final class PublishHandler implements OperationHandlerInterface
{
    public function supports(
        ServerRequestInterface $request,
        string $operation,
        ApiDefinition $config
    ): bool {
        return $operation === 'publish'
            && $config->table === 'tx_myext_domain_model_article';
    }

    public function handle(
        ServerRequestInterface $request,
        ApiDefinition $config
    ): ResponseInterface {
        $uid = (int) $request->getAttribute('tca_api.uid');
        // … publish logic …
    }

    public function getPriority(): int
    {
        return 10;
    }
}
Copied!

Registering handlers 

No ext_localconf.php changes are needed. TCA_API's Configuration/Services.yaml contains an _instanceof rule that automatically tags every class implementing OperationHandlerInterface with tca_api.operation_handler. The HandlerRegistry collects all tagged services via Symfony's AutowireIterator and sorts them by priority.

All that is required is that the handler class is discoverable by the DI container. If your extension uses the standard service auto-discovery pattern (resource: '../Classes/*'), nothing else is needed. The dispatcher iterates handlers highest priority first and dispatches to the first match.

To override a built-in handler, return a priority higher than 10:

public function getPriority(): int
{
    return 20; // checked before the built-in handler (priority 10)
}
Copied!

Request Attributes 

Before the handler loop, the dispatcher sets the following attributes on the PSR-7 request. These are available in operation handlers and event listeners.

Attribute Type Description
tca_api.uid int|null UID parsed from the URL segment (null for collection operations).
tca_api.operation string Resolved operation name (list, show, create, update, delete).
tca_api.fields array Sparse-fieldset parameter from ?fields[]=….
tca_api.page int Pagination page number (≥ 1).
tca_api.items_per_page int Items per page for the current request (clamped to maxItemsPerPage when configured).
tca_api.filters array Raw filter parameters from ?filters[…]=….
tca_api.order array Raw order parameters from ?order[…]=asc|desc.
tca_api.partial bool true for PATCH requests (partial update), false otherwise.

Usage example 

public function handle(
    ServerRequestInterface $request,
    array $config
): ResponseInterface {
    $uid = (int) $request->getAttribute('tca_api.uid');
    $operation = $request->getAttribute('tca_api.operation');
    $filters = $request->getAttribute('tca_api.filters', []);
    $page = $request->getAttribute('tca_api.page', 1);

    // …
}
Copied!

Architecture 

This chapter describes the high-level architecture of TCA_API and how a request flows through the system.

Request flow 

HTTP Request
    ↓
TcaApiMiddleware (PSR-15 entry point)
    ↓
RequestDispatcher (path parsing, access control, handler dispatch)
    ↓
OperationHandlers (GetCollectionHandler, GetItemHandler, CreateHandler, …)
    ↓
DataRepository (QueryBuilder reads) ↔ DataWriteService (DataHandler writes)
    ↓
ResourceSerializer (DB row → Hydra JSON-LD)
    ↓
HydraResponseBuilder (JSON-LD response assembly)
Copied!

Key components 

TcaApiMiddleware
PSR-15 middleware that intercepts requests matching the configured API prefix. Checks if the API is enabled for the current site, adds CORS headers if configured, and delegates to RequestDispatcher.
RequestDispatcher
Parses the URL path to determine the resource and operation. Enforces the operation whitelist, checks access control via AccessController, dispatches BeforeOperationEvent, and routes to the matching operation handler via HandlerRegistry.
Operation Handlers

Implement OperationHandlerInterface. Built-in handlers:

  • GetCollectionHandler — list operation
  • GetItemHandler — show operation
  • CreateHandler — POST operation
  • UpdateHandler — PUT/PATCH operations
  • DeleteHandler — DELETE operation
  • GetUserInfoHandler — userinfo operation
DataRepository
Read-only data access via TYPO3 QueryBuilder. Supports filter strategies, sorting, pagination, and PID constraints.
DataWriteService
Wraps TYPO3's DataHandler for create, update, and delete operations. Ensures data integrity and respects TCA rules.
ResourceSerializer
Converts raw database rows to Hydra JSON-LD arrays. Handles column visibility, sparse fieldsets, relation embedding, file processing, and virtual properties.
HydraResponseBuilder
Assembles the final JSON-LD response structure including @context, @type, hydra:member (for collections), hydra:view (pagination), and violations (for validation errors).
ApiRegistry
Static registry that stores all resource configurations. Populated automatically by ApiDefinitionLoader at boot time.
HandlerRegistry
Static registry for operation handlers with priority-based dispatch.
AccessController
Evaluates AccessRole enum values and callable voters against the current request context.

Configuration Module 

When typo3/cms-lowlevel is installed, TCA_API registers a provider in the TYPO3 backend Configuration module (System → Configuration). The provider displays all loaded resource definitions as an interactive tree, which is useful for debugging your setup and verifying that definitions are loaded and parsed as expected.

TCA_API configuration tree in the TYPO3 Configuration module

What is shown 

Each resource definition is listed by its resourceName and expanded into all resolved properties. All values reflect the parsed state after ApiDefinitionLoader has normalised the raw PHP arrays — so what you see is exactly what the extension acts on at runtime.

Known Problems 

Current state 

TCA_API is in alpha state (version 0.1.0). While the core functionality is stable and tested, the API surface may change between minor releases.

Planned features 

The following features are planned but not yet implemented:

  • Custom route patterns — support for custom URL patterns like /user/current (medium priority).
  • Inline relation field selection — select specific fields from related records when embedding (high priority).
  • Relation filtering in serialization — filter related records during serialization (low priority).

Reporting issues 

Please report bugs and feature requests on the GitHub issue tracker.