---
title: "Resource Definition"
manual: "TCA_API"
version: "main"
permalink: "https://docs.typo3.org/permalink/maikschneider/tca-api:resource-definition@main"
source: "Configuration/ResourceDefinition.rst"
rendered: "2026-10-27T16:14:15+00:00"
---

# Resource Definition {#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](https://docs.typo3.org/permalink/maikschneider/tca-api:configuration-overrides@main).

## General section {#general-section}

The `general` key defines the basic resource properties:

```php
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,
    ],
];
```

| 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](https://docs.typo3.org/permalink/maikschneider/tca-api:userinfo@main). |
| `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, or a `{$site.setting}` placeholder resolved per site (see [Storage pages per site](https://docs.typo3.org/permalink/maikschneider/tca-api:site-storage-pages@main)). Also constrains reads to this page unless `readStoragePids` is set. When omitted, reads are not pid-constrained and writes target page 0. Any other value fails at boot. |
| `readStoragePids` | Widens the read-side pid constraint independently of `storagePid`. Accepts an array (`[42, 17, 5]`) or comma-separated list (`'42,17,5'`) — reads then use `pid IN (...)` — or the sentinel `'*'` to read from all pages regardless of the write target. Entries may be `{$site.setting}` placeholders. When omitted, reads fall back to `storagePid`. This is the complete read set: list the write page explicitly if it should also be readable. |
| `recordTypes` | Scopes the resource to record types of the table's TCA `ctrl.type` field (`CType`, `doktype`, …). Accepts an array (`['textmedia', 'header']`), a comma-separated list or `'*'` for every type in `$TCA[table]['types']`. In default mode each record then exposes only its type's columns; the options form (`['include' => …, 'schema' => 'table']`) keeps all table columns, and `'split' => true` adds one resource per type (see [One resource per record type](https://docs.typo3.org/permalink/maikschneider/tca-api:record-type-split@main)). When omitted, the resource serves records of every type with all columns — see [Record types](https://docs.typo3.org/permalink/maikschneider/tca-api:record-types@main). |
| `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. |
| `group` | OpenAPI tag grouping this resource's operations in Swagger UI. A string (`'Editorial'`) or an array with a `name` and optional `description`. Defaults to `resourceType` — see [Endpoint grouping (OpenAPI tags)](https://docs.typo3.org/permalink/maikschneider/tca-api:endpoint-grouping@main). |

## Write mode {#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.

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

> [!WARNING]
> `system_admin` mode bypasses all TYPO3 access-control restrictions on
> writes. Never use it for public-facing endpoints.

## Storage pages (read vs. write) {#storage-pages-read-vs-write}

`storagePid` is the **single write target** — newly created records land
there. By default it also constrains reads to that one page. To read from
several pages while still writing into one, add `readStoragePids`:

```php
'general' => [
    'table'           => 'tx_myext_domain_model_article',
    'resourceName'    => 'articles',
    'resourceType'    => 'Article',
    'operations'      => ['list', 'show', 'create'],
    'storagePid'      => 42,            // writes go here
    'readStoragePids' => [42, 17, 5],   // reads span these pages
],
```

Use the sentinel `'*'` to read from every page regardless of the write
target:

```php
'storagePid'      => 42,    // writes go here
'readStoragePids' => '*',   // reads ignore pid entirely
```

`readStoragePids` is the complete read set — it does **not** implicitly
include `storagePid`. List the write page explicitly if it should be
readable too.

### Storage pages per site {#site-storage-pages}

When one resource serves several sites that keep their records on their own
page, point `storagePid` and `readStoragePids` at a site setting. The
`{$…}` placeholder is resolved from the settings of the site that receives
the request:

```php
'general' => [
    'table'           => 'tx_myext_entry',
    'resourceName'    => 'entries',
    'resourceType'    => 'Entry',
    'storagePid'      => '{$myext.storagePid}',
    'readStoragePids' => ['{$myext.storagePid}', '{$myext.sharedPid}'],
],
```

**config/sites/tenant-a/config.yaml**

```yaml
settings:
  myext:
    storagePid: 12
    sharedPid: 3
```

The resolved pid scopes every operation: `list` and `show` read only the
site's pages, `update` and `delete` of another site's record answer
`404`, and `create` stores the record, its inline-created relations and
its file references on the site's page.

A placeholder fails closed. When the setting is missing, empty, boolean, negative or
not an integer, the request answers `500` (the message names the setting
when `tca_api.debugMode` is on) and no query runs without the pid
constraint.

Response caching includes the site and its resolved read pages. Changing a
storage-page setting cannot reuse a response from the previous scope, and
an invalid setting fails even when a response was previously cached.

## Record types {#record-types}

Tables with a TCA `ctrl.type` field — `tt_content.CType`, `pages.doktype`
— hold records of several types. `recordTypes` limits a resource to some of
them:

```php
'general' => [
    'table'        => 'tt_content',
    'resourceName' => 'content',
    'resourceType' => 'Content',
    'operations'   => ['list', 'show', 'create', 'update'],
    'recordTypes'  => ['textmedia', 'header'],
],
```

The scope applies to every operation, not only to `list`: a record of
another type answers `404` on `show`, `update` and `delete`, exactly
like a record on another page. A filter on the type column cannot do that,
because filters only narrow collections.

On writes:

-   A resource with **one** record type sets the type field on `create`
    itself. A client may omit it; a different value is a `422`.
-   A resource with **several** record types requires the type field on
    `create` and rejects values outside the list with `422`
    (`INVALID_RECORD_TYPE`).
-   `update` cannot move a record to a type outside the list.

The OpenAPI schema describes the type field as an `enum` of the allowed
types. Unknown types, tables without `ctrl.type` and foreign-field types
(`'type' => 'local_field:foreign_field'`) fail at boot.

> [!NOTE]
> Relations are not scoped: a column referencing records of the table
> renders them regardless of `recordTypes`, as it does for
> `storagePid`.

### Recipe: pages {#recipe-pages}

`pages.doktype` works like `CType`. Most page types are not content for
an API: sysfolders (`254`), the recycler (`255`), spacers (`199`) and
backend user sections (`6`). Scope a pages resource to the types a frontend
renders:

```php
'general' => [
    'table'        => 'pages',
    'resourceName' => 'pages',
    'resourceType' => 'Page',
    'recordTypes'  => [1, 3, 4],   // standard, link, shortcut
],
```

Pages of other types answer `404`, also on `show` by uid. Each page
exposes the fields of its type: a link page carries its URL (`link`, or
`url` before TYPO3 v14) resolved like any typolink field, and a shortcut
page carries `shortcut_mode` and `shortcut` as a link to the target page.

### Columns per record type {#columns-per-record-type}

In default mode (no column declares `groups`), each record exposes only the
columns of **its type's form** — the fields in the type's `showitem`, with
palettes unpacked, in the same order. A `header` content element has no
`bodytext` or `assets`, a `textmedia` element does. System fields that
palettes carry (`hidden`, `starttime`, `sys_language_uid`, …) stay hidden
as everywhere else. The type field is always included, even for a type whose
form does not show it.

The type's `columnsOverrides` apply as well. A `required` or `max` set
for one type is validated only for records of that type, and FlexForm and
field processing use the type's configuration.

-   A collection over several types returns each item with its own columns.
-   On writes, columns outside the target type's form are ignored — the type
    being created or switched to, otherwise the record's current type.
-   The OpenAPI `Read` schema of a multi-type resource is a `oneOf` over
    one schema per type (`ContentTextmediaRead`, `ContentHeaderRead`, …)
    with a `discriminator` on the type field. A single-type resource's
    `Read` and `Write` schemas describe that type. The `Write` schema of
    a multi-type resource lists the columns of all its types, because `PATCH`
    bodies are partial and cannot be matched against a single type.

Declared `columns` keep their configuration but count only for the types
whose form contains them. Their TCA-derived validation follows the type's
`columnsOverrides` too, while an explicit `required` or validator of the
same kind still takes precedence. Explicit mode is unaffected: declared columns are
the columns.

To scope a resource without changing its columns, use the options form with
`'schema' => 'table'`:

```php
'recordTypes' => [
    'include' => ['textmedia', 'header'],   // default: '*'
    'schema'  => 'table',                   // default: 'type'
],
```

### One resource per record type {#record-type-split}

`'split' => true` expands the resource into one resource per included type,
for clients that work with one type at a time. Each generated resource is the
base config scoped to a single type, so it behaves like a hand-written
single-type resource: `create` sets the type, other types answer `404`.

```php
'general' => [
    'table'        => 'tt_content',
    'resourceName' => 'content',
    'resourceType' => 'Content',
    'recordTypes'  => [
        'include'     => ['textmedia', 'header'],   // default: '*'
        'split'       => true,
        'namePattern' => '{resourceName}-{type}',   // default
        'keepBase'    => true,                      // default
    ],
],
'types' => [
    'textmedia' => [
        'security' => ['create' => AccessRole::FE_USER],
    ],
],
```

This registers `content-textmedia` (`ContentTextmedia`) and
`content-header` (`ContentHeader`) next to `content`.

-   `namePattern` sets the generated `resourceName`; the `resourceType` is
    its PascalCase form. A name must contain a letter and may contain letters,
    digits, `-` and `_` only, because it is the URL segment.
-   `keepBase => false` drops the multi-type base resource. Relations to the
    table that set no `resourceName` then link to the first generated
    resource, which serves only its own type. Keep the base, or set the
    relation column's `resourceName`, when other resources link to the table.
-   `types` holds per-type overrides, merged over the base config of that
    type's resource only. Nested arrays merge key by key, lists such as
    `operations` or the groups of an access role are replaced as a whole.
    An override may set `general.resourceName` and
    `general.resourceType` as well, but not `general.recordTypes.include`:
    a generated resource serves only its own type. `types` without `split` fails at
    boot.
-   The expansion runs after the `Overrides/` pass, so an override file that
    edits the base config reaches every generated resource.
-   Without a `group`, each generated resource gets its own Swagger UI
    section, described with the type's label from the TCA `items`.
-   A generated name that is already taken fails at boot.

> [!WARNING]
> Generated resources inherit the base `security`. Reads are public by
> default, so splitting `'*'` on `tt_content` publishes one public
> endpoint per content type. Restrict `security` in the base config or in
> `types`, and add the generated names to the `tca_api.allowedResources`
> site setting if you use it.

## Endpoint grouping (OpenAPI tags) {#endpoint-grouping}

By default each resource gets its own Swagger UI section, named after its
`resourceType`. Set `group` to place several resources under a shared,
named section:

```php
'general' => [
    'table'        => 'tx_myext_domain_model_article',
    'resourceName' => 'articles',
    'resourceType' => 'Article',
    'group'        => 'Editorial',
],
```

Use the array form to add a description shown above the section:

```php
'group' => [
    'name'        => 'Editorial',
    'description' => 'Editorial content endpoints',
],
```

Point several resources at the same `name` to merge them into one section.
Sections appear in resource-registration order; the first configured
`description` for a group wins. Omitting `group` falls back to
`resourceType`, so grouping is fully opt-in and backward compatible.

## Minimal example (zero-config) {#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:

```php
return [
    'general' => [
        'table'        => 'tx_myext_domain_model_article',
        'resourceName' => 'articles',
        'resourceType' => 'Article',
    ],
];
```

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

```php
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,
    ],
];
```

## Full example {#full-example}

```php
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,
    ],
];
```
