Documentation 

This page explains what the extension's documentation covers and where to find it. The docs are written for developers who build a headless TYPO3 frontend with EXT:headless and EXT:content_blocks — see the README whether the extension fits your setup. The same documentation is rendered on docs.typo3.org.

Getting started 

  • Getting started — install the extension, include the Site Set, and verify your first JSON response

Concepts (why it works this way) 

  • Architecture — the normalization pipeline from Content Block record to JSON: DataProcessor, RecordArrayBuilder, normalizers, field value transformers, and the extension points

How-to guides (solve a task) 

Reference (look it up) 

Troubleshooting 

Design records (internal) 

Design/ holds planning and analysis records — where wording differs from the code, the code wins. Notable: Improve ToArray design record — the 2026-08 rewrite of the ToArray conversion (normalizer registry, Schema API migration, declarative image variants) with its decisions and rationale.

Archive (internal) 

_archive/ holds superseded documentation — how the extension got here, not how it works today. Notable: legacy thumbnails via headless.php — the ImageViewHelper pattern that declarative image variants replaced.

Getting started 

This page walks you through installing the extension and verifying your first JSON output — it is a linear tutorial; background and next steps follow at the end.

Prerequisites 

  • TYPO3 ≥ 13.4 (13.4 and 14.3 are tested in CI)
  • A headless frontend based on EXT:headless ≥ 4.5
  • Content Blocks created with EXT:content_blocks ≥ 1.2.3
  • Composer — the recommended installation method; the extension is also published to the TER (from version 0.1.2)

EXT:headless and EXT:content_blocks are installed automatically as dependencies when you require this extension.

Install 

composer require netzbewegung/nb-headless-content-blocks
Copied!

Then include the extension's Site Set in your site package's config/sites/<site>/config.yaml:

dependencies:
  - nb-headless-content-blocks/headless-content-blocks
Copied!

That's it — no TypoScript mapping is needed for your Content Blocks: EXT:content_blocks maps them automatically onto lib.contentBlock (see How it works).

Verify 

Open a page of your headless site that contains a Content Block element. The JSON response of the page should contain the block with a data object whose keys are the field identifiers from your Content Block YAML (fields: - identifier: my_field), alphabetically sorted:

{
    "id": 1,
    "type": "vendor_mycontentblock",
    "colPos": 0,
    "data": {
        "header": "My header",
        "my_datetime": "2023-10-20T14:08:34+00:00",
        "my_link": {
            "url": "https://example.com",
            "target": "",
            "type": "url",
            "title": "https://example.com",
            "config": { "parameter": "https://example.com" },
            "attr": { "href": "https://example.com" }
        },
        "my_text": "Some text"
    }
}
Copied!

The id/type/colPos wrapper is built by EXT:headless around this extension's processor output; everything inside data comes from nb-content-blocks-json. The exact shapes per field type are listed in the JSON contract.

If the block renders but a field is missing or null, see Troubleshooting.

How it works 

lib.contentBlock comes from EXT:content_blocks — a FLUIDTEMPLATE served by the content-blocks data processor. For every Content Block with a frontend template (templates/frontend.html), EXT:content_blocks auto-generates the mapping

tt_content.vendor_mycontentblock =< lib.contentBlock
Copied!

This extension's Site Set replaces lib.contentBlock with a clone of EXT:headless' lib.contentElement whose fields.data is produced by the nb-content-blocks-json data processor:

lib.contentBlock < lib.contentElement
lib.contentBlock {
    fields {
        data = TEXT
        data {
            dataProcessing {
                10 = nb-content-blocks-json
                10.as = data
            }
        }
    }
}
Copied!

Because the auto-generated mapping points at lib.contentBlock, your Content Blocks render as JSON without any TypoScript on your side. Only two cases need a manual mapping:

  • Content Blocks without a frontend template are not auto-mapped.
  • Custom content elements that are not Content Blocks.
tt_content.vendor_puredatablock =< lib.contentBlock
Copied!

How the processor turns a record into the data JSON is covered in Architecture.

Next steps 

Architecture 

This page explains how a Content Block record becomes JSON — the pipeline, the building blocks and the extension points. It is background, not a tutorial; for concrete tasks see the how-to guides.

The short version 

The extension's whole job is array shaping: it takes rich TYPO3 domain objects and turns them into plain, JSON-compatible arrays — with a stable, frozen output contract. Object resolution is done by the TYPO3 Core (RecordFactory); key mapping, field ordering and value shaping are done here. That split is deliberate: it is what allows the same code to run on TYPO3 13 and 14 without version forks.

The pipeline 

TypoScript data processor (Site Set: lib.contentBlock)
 └─ nb-content-blocks-json  (ContentBlocksJsonDataProcessor)
     ├─ RecordFactory::createResolvedRecordFromDatabaseRow()   [Core]
     │    DB row → rich objects (Record, FileReference, TypolinkParameter,
     │    DateTimeImmutable, LazyRecordCollection, ...)
     └─ RecordArrayBuilder::build()
          ├─ strip system fields (uid, pid, colPos, CType, ...)
          ├─ map columns → Content Block field identifiers
          │    (tx_myext_field → my_field, via ContentBlocksIdentifierMapper)
          ├─ dispatch ModifyArrayRecursiveToArrayEvent per field (deprecated)
          ├─ strings → FieldValueTransformerChain
          │    (PasswordBlanker, RichtextParser — schema-driven)
          ├─ everything else → NormalizerChain
          │    first registered normalizer with supports() === true wins;
          │    unknown types → UnknownTypeNormalizer (null + debug log)
          └─ ksort
 └─ headless.php include (optional, per Content Block)
 └─ sub data processors (optional, TypoScript dataProcessing.)
Copied!

nb-container-json (ContainerJsonDataProcessor) follows the same shape for EXT:container children: it fetches the children of a container column and renders each through the same RecordArrayBuilder.

Building blocks 

Building block Purpose
DataProcessing/ContentBlocksJsonDataProcessor TypoScript entry point (nb-content-blocks-json): resolves the record, delegates to RecordArrayBuilder, includes headless.php, runs sub data processors
DataProcessing/ContainerJsonDataProcessor Same for EXT:container children (nb-container-json), via b13/container's ContainerProcessor
Normalization/RecordArrayBuilder Orchestrates one record: system fields, identifier mapping, event, transformers, normalizers, ksort
Normalization/NormalizerChain Iterates the tagged normalizers; falls back to UnknownTypeNormalizer
Normalization/Normalizer/* One class per value type — see Normalizers and transformers
FieldTransformer/* String shaping driven by the field's schema type (password → "", richtext → parseFunc_RTE)
ContentBlocks/ContentBlocksIdentifierMapper Column → field identifier mapping; the only ContentBlocks definition still in the conversion path
ContentBlocks/HeadlessYamlLoader Loads the optional per-block headless.yaml (declarative image variants), with caching
Normalization/Context Per-run state: current TcaSchema, request and ContentObjectRenderer of the originating DataProcessor, TypoScript options., per-field image processing; lets normalizers recurse without circular DI

Why normalizers (and not one big converter) 

Before the 2026-08 rewrite, one class held a giant switch (true) with instanceof chains over ContentBlocks internals, makeInstance() everywhere, and version hacks (property_exists(...)) to construct those internals in tests. The rewrite (see the design record) replaced it with a normalizer registry — the Symfony Serializer pattern, hand-rolled without the dependency:

  • each normalizer is small and independently testable,
  • site packages can add their own via DI tag (nb_headless.normalizer),
  • field metadata comes from the Core Schema API (TcaSchemaFactory), which ships in TYPO3 13 and 14 — the version hacks disappeared because the code stopped constructing ContentBlocks internals.

ContentBlocks stays a hard dependency for exactly two things that have no Core equivalent: the identifier mapping (dropping it would change every consumer's JSON contract) and resolving the Content Block folder (headless.php, headless.yaml).

Design principles 

  • The JSON contract is the product. Output shapes are frozen by characterization tests (ContentBlocksJsonDataProcessorCharTest) that assert the complete JSON per fixture record. A refactor must not change them; a contract change is a deliberate, documented decision.
  • Never break the response. Unknown value types become null with a debug log entry; a missing file becomes an __errorMessage entry in place — the page still renders.
  • Config before code. What used to need a headless.php (image variants) is now declarative (headless.yaml + TypoScript override). headless.php remains as an escape hatch.

Extension points 

Extension point Mechanism Use for Documentation
Normalizer DI tag nb_headless.normalizer own value types in the JSON output How-to
Field value transformer DI tag nb_headless.field_value_transformer own string field shaping How-to
headless.php per-Content Block PHP file per-block post-processing of the whole data array How-to
ModifyArrayRecursiveToArrayEvent PSR-14 event per field legacy field overrides How-to (deprecated)
Sub data processors TypoScript dataProcessing. TypoScript-computed data (menus, record lists) How-to
Image variants headless.yaml + TypoScript options.processing. responsive thumbnails How-to

When several extension points could solve a problem, prefer the most declarative one (image variants over headless.php, transformer over event).

How-to guides 

Step-by-step guides for common tasks around the JSON output of EXT:nb_headless_content_blocks.

Add sub data processors 

This guide shows how to add TypoScript-computed data — menus, record lists, plugin output — to a Content Block's data.

The problem it solves 

Content Block fields hold editor-managed content. Sometimes the JSON needs computed data: a page tree menu, a list of the latest news records, or anything else a TYPO3 data processor can produce. Instead of writing that in headless.php, wire standard data processors via TypoScript.

Wire a sub data processor 

tt_content.vendor_yourcontentblockelement.fields.data.dataProcessing.10 {
    dataProcessing {
        10 = menu
        10 {
            levels = 2
            as = navigation
        }
    }
}
Copied!

What happens:

  • The path addresses the nb-content-blocks-json processor (setup position 10 inside fields.data.dataProcessing) that the Site Set defines for lib.contentBlock.
  • Inside it, dataProcessing. works exactly like everywhere else in TypoScript — any registered data processor can be used (menu, database-query, files, your own ...).
  • The as name of each sub processor becomes a new key in data (here: data.navigation).

Result 

{
    "data": {
        "header": "My header",
        "navigation": [
            { "title": "Home", "link": "/" },
            { "title": "Products", "link": "/products/" }
        ]
    }
}
Copied!

The sub-processor results are merged into data after the Content Block fields; the internal keys data and current are stripped from the result first, so they cannot collide with your fields.

Notes 

  • Sub data processors run with the block's ContentObjectRenderer context — field: references in the sub processor resolve against the block's record.
  • This mechanism is used in production (e.g. dynamic card lists rendered by an additional processor with as = cards).
  • Prefer it over headless.php for anything TypoScript already provides.

Define image variants 

This guide shows how to get responsive thumbnail URLs next to the original publicUrl of File fields — declaratively, without PHP. It replaces the former pattern of generating thumbnails inside headless.php.

The problem it solves 

Headless frontends need each image in several sizes (mobile, desktop, 2x variants, webp). Before this feature, every Content Block carried a headless.php with a handwritten thumbnail generator — on the production site this extension was built for, 13 identical headless.php files existed, all doing the same thing with different widths. The old pattern is archived here: legacy thumbnails via headless.php; for migrating existing blocks see Migrate legacy thumbnails.

Define variants in headless.yaml 

Create a headless.yaml next to the Content Block's config.yaml:

your_extension/ContentBlocks/ContentElements/your-block/headless.yaml
Copied!
fields:
  image:
    processing:
      mobile:     "width=883c,fileExtension=webp"
      mobile2x:   "width=1766c,fileExtension=webp"
      desktop:    "width=1564c,fileExtension=webp"
      desktop2x:  "width=3128c,fileExtension=webp"
Copied!

Schema rules:

  • Keys below fields are field identifiers (as in config.yaml), not database columns.
  • Keys below processing are free variant names — they become the keys of the thumbnails map.
  • Values are processing instruction strings: key=value pairs, comma-separated (width=883c,fileExtension=webp) — the same syntax EXT:headless uses in its processors, and the same instructions TYPO3's image processing accepts (width, height, fileExtension, crop, ...). The c suffix means crop-scaling to exactly that width.

The parsed file is cached; run ddev typo3 cache:flush (or clear caches in the Backend) after changes.

The result 

Every File field with variants gets a thumbnails map in addition to the frozen base shape — for oneToOne and oneToMany relationships alike:

{
    "image": {
        "id": 1,
        "alt": "",
        "title": "",
        "publicUrl": "https://example.com/fileadmin/image.jpg",
        "thumbnails": {
            "mobile": "https://example.com/fileadmin/_processed_/c/a/mobile.jpg",
            "desktop": "https://example.com/fileadmin/_processed_/7/3/desktop.jpg"
        }
    }
}
Copied!

Fields without variants keep the plain id/alt/title/publicUrl shape.

Override variants per site (TypoScript) 

headless.yaml is versioned with the Content Block and is the default. A site package can override or add variants per field via the processor option options.processingTypoScript wins on conflict, headless.yaml variants without a TypoScript counterpart stay:

tt_content.vendor_myblock.fields.data.dataProcessing.10 {
    options {
        processing {
            image {
                desktop = width=1600c,fileExtension=webp
                xl = width=2400c,fileExtension=webp
            }
        }
    }
}
Copied!

All other processor options work as usual.

Troubleshooting 

If thumbnails do not show up, see Troubleshooting → thumbnails are missing.

Migrate legacy thumbnails to headless.yaml 

This guide shows how to replace a legacy headless.php thumbnail generator (the archived ImageViewHelper pattern) with declarative image variants.

Identify the block 

Legacy generators all look the same: a headless.php next to the block's config.yaml that fills $data[...]['image']['thumbnails'] with URLs from a ThumbnailUtility or an inline ImageViewHelper closure.

Note down, per image field:

  • the field identifier the thumbnails belong to (image, media, …)
  • the variant names (mobile, desktop, mobile2x, …)
  • the widths/extensions used per variant

Replace the generator 

For a File field directly on the Content Block, translate each generateThumbnail([...]) call into one headless.yaml entry:

Legacy (ImageViewHelper argument) Declarative (processing string)
'width' => 320 width=320
'width' => 883 with crop-scaling intent width=883c
'height' => 400 height=400
'fileExtension' => 'webp' fileExtension=webp
'src' => $image['id'], 'treatIdAsReference' => true not needed — the processor knows the field's FileReference
'absolute' => true not needed — URLs are always absolute

The legacy example from the archive:

$data['items'][$itemKey]['image']['thumbnails'] = [
    'mobile' => $generateThumbnail(['src' => $image['id'], 'treatIdAsReference' => true, 'width' => 320]),
    'desktop' => $generateThumbnail(['src' => $image['id'], 'treatIdAsReference' => true, 'width' => 800]),
];
Copied!

becomes — for a direct image field on the block —

fields:
  image:
    processing:
      mobile: "width=320"
      desktop: "width=800"
Copied!

Then delete the thumbnail part from headless.php (or the whole file, if it did nothing else). The output shape stays identical: the same thumbnails map appears next to id/alt/title/publicUrl.

Verify 

Clear caches (ddev typo3 cache:flush), reload the page JSON and compare the thumbnails URLs of the block before/after the migration — same variant names, same dimensions. The block's other fields must be byte-identical.

Per-site overrides 

Sites that need different widths than the block ships no longer patch the PHP — they override via TypoScript (TypoScript wins per variant):

tt_content.vendor_myblock.fields.data.dataProcessing.10 {
    options {
        processing {
            image {
                desktop = width=1600c,fileExtension=webp
            }
        }
    }
}
Copied!

Known limitation 

The declarative variants currently cover File fields of the Content Block record itself. Images inside Collection items (e.g. $data['items'][...]['image'] — exactly the archived example's shape) are not yet covered: nested collection records are built without the processor options, and their custom table has no Content Block headless.yaml. For that case, keep the headless.php generator for now — follow the design record (Improve ToArray) for the planned extension of the Context-based API.

Modify fields with the PSR-14 event 

This guide shows the legacy way of overriding single field values — the ModifyArrayRecursiveToArrayEvent. It is deprecated and kept for backwards compatibility; prefer normalizers or field value transformers for new code.

Why it is deprecated 

The event predates the normalizer registry. It runs per field on every record conversion, carries ContentBlocks internals in its payload, and cannot express value-type dispatch cleanly. The modern equivalents:

Legacy (event) Modern
Override by field name Field value transformer (strings)
Override by value type Custom normalizer
Whole-block post-processing headless.php

The event keeps firing with its original payload until the next minor release; listeners continue to work unchanged until then.

The listener 

<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\EventListener;

use Netzbewegung\NbHeadlessContentBlocks\Event\ModifyArrayRecursiveToArrayEvent;
use TYPO3\CMS\Core\Attribute\AsEventListener;

#[AsEventListener]
final class MyCustomListener
{
    public function __invoke(ModifyArrayRecursiveToArrayEvent $event): void
    {
        // Custom handling by field name (column!)
        if ($event->getKey() === 'tx_my_vendor_field_name') {
            $processedValue = strtoupper((string)$event->getValue());
            $event->setProcessedValue($processedValue);
        }

        // Custom handling for all text fields
        if (
            $event->getTcaFieldDefinition() !== null
            && $event->getTcaFieldDefinition()->fieldType instanceof \TYPO3\CMS\ContentBlocks\FieldType\TextFieldType
        ) {
            $event->setProcessedValue(strtoupper((string)$event->getValue()));
        }
    }
}
Copied!

Semantics:

  • getKey() is the database column (pre identifier mapping) — tx_my_vendor_field_name, not my_field.
  • setProcessedValue() marks the field as handled; the value lands in the JSON under the mapped field identifier.
  • Unhandled fields fall through to the normal conversion.
  • The event fires for every field of every record — return fast.

Migration 

Replace setProcessedValue($value) calls with a transformer (string values) or normalizer (everything else). The migrated code is smaller, testable in isolation, and independent of ContentBlocks internals.

Post-process JSON with headless.php 

This guide shows how to modify a Content Block's complete data array in PHP — the escape hatch when no declarative feature covers your case.

When to use it (and when not) 

Reach for headless.php only for what the built-in features cannot do:

Need Use instead
Responsive image variants Define image variants (headless.yaml)
Menus, record lists, TypoScript data Add sub data processors
Custom value/string shaping Custom normalizer / transformer

headless.php remains for genuinely block-specific logic — merging two fields, calling an API, reshaping the whole payload.

Maintenance note: on the production site this extension was built for, 13 headless.php files existed whose only job was thumbnail generation. All of them are candidates for deletion since declarative image variants exist — see Migrate legacy thumbnails and the archived legacy pattern.

Create the file 

Place a headless.php next to the Content Block's config.yaml:

your_extension/ContentBlocks/ContentElements/your-block/headless.php
Copied!

The file receives the fully built data array and returns the modified one:

<?php

declare(strict_types=1);

// $data: the complete JSON array of this block, field identifiers as keys

foreach ($data['items'] ?? [] as $itemKey => $item) {
    $data['items'][$itemKey]['combinedTitle'] = trim($item['title'] . ' ' . $item['subtitle']);
}

// You can also add completely new keys...
$data['lastModified'] = date(\DateTimeInterface::W3C);

// ...or remove fields from the output
unset($data['subtitle']);

return $data;
Copied!

Rules:

  • The file is plain PHP included via require — keep it side-effect free apart from building the return value.
  • Return the array. A file that returns anything else (e.g. no return statement at all) throws an exception in a Development application context; in Production the processor falls back to the unmodified data.
  • The file runs on every render of this block type.

Compatibility 

The extension plans to pass a second parameter with the normalization Context (function (array $data, Context $context)) — not yet part of the stable API. Do not rely on it yet; see the design record (decision 4).

Troubleshooting 

Changes do not show up? headless.php is executed on every request — check that the file is really next to the block's config.yaml and that you are editing the Content Block the record actually uses (CType).

Register a custom normalizer 

This guide shows how to add your own value types to the JSON output — for domain objects or field types the built-in normalizers do not cover.

When you need one 

A normalizer claims a value type. You need one when a field value arrives at the end of the chain and would become null (see Troubleshooting) — typically because your extension introduced a custom relation type, or you want to change the shape of an existing one (e.g. categories with more fields than the frozen uid/pid/title shape).

Normalizers are consulted in registration order; the first one whose supports() returns true wins. To replace built-in behavior, claim the same type with a higher priority.

Implement the interface 

<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\Normalization;

use Netzbewegung\NbHeadlessContentBlocks\Normalization\Context;
use Netzbewegung\NbHeadlessContentBlocks\Normalization\NormalizerInterface;
use TYPO3\CMS\Core\Resource\FileReference;

final class SquareThumbnailNormalizer implements NormalizerInterface
{
    public function supports(mixed $value, Context $context): bool
    {
        // claim only what you really want to shape
        return $value instanceof FileReference && ($context->getOption('square') === true);
    }

    public function normalize(mixed $value, Context $context): mixed
    {
        // must return a JSON-compatible value (array|string|int|float|null)
        return [
            'publicUrl' => $value->getPublicUrl(),
        ];
    }
}
Copied!

The Context gives you the current TcaSchema, the request, and the processor options. (TypoScript). Normalizers may recurse: call $context->getChain()->normalize($nestedValue, $context) for nested rich values, and $context->buildRecord($record) to run a related record through the full conversion (identifier mapping, transformers, event).

Register the service 

In your extension's Configuration/Services.yaml:

services:
  MyVendor\MyExtension\Normalization\SquareThumbnailNormalizer:
    tags:
      - name: 'nb_headless.normalizer'
        priority: 100        # higher = earlier in the chain (default 0)
Copied!

No other wiring needed — the NormalizerChain receives all tagged services via a tagged iterator.

Built-in normalizers 

See Normalizers and transformers for the built-in chain and its frozen output shapes — do not change them accidentally: the shapes are part of the JSON contract.

Register a field value transformer 

This guide shows how to change how string field values are shaped in the JSON output — the same mechanism that blanks passwords and parses richtext.

When you need one 

Field value transformers shape plain string values based on the field's schema type (FieldTypeInterface — TCA/Schema API knowledge: field type, configuration, richtext flag, ...). Use one when:

  • a specific field type needs a different string representation (e.g. trim, mask, map color names),
  • you want to blank or normalize certain inputs the way PasswordBlanker blanks password fields.

For whole objects (files, links, relations) use a custom normalizer instead — transformers only ever see strings.

Implement the interface 

<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\FieldTransformer;

use Netzbewegung\NbHeadlessContentBlocks\FieldTransformer\FieldValueTransformerInterface;
use Netzbewegung\NbHeadlessContentBlocks\Normalization\Context;
use TYPO3\CMS\Core\DataHandling\TableColumnType;
use TYPO3\CMS\Core\Schema\Field\FieldTypeInterface;

final class ColorNameTransformer implements FieldValueTransformerInterface
{
    private const COLOR_NAMES = [
        '#ff0000' => 'red',
        '#00ff00' => 'green',
    ];

    public function supports(FieldTypeInterface $field): bool
    {
        // claim Color fields (Content Block type "Color")
        return $field->getType() === TableColumnType::COLOR->value;
    }

    public function transform(string $value, FieldTypeInterface $field, Context $context): string
    {
        return self::COLOR_NAMES[strtolower($value)] ?? $value;
    }
}
Copied!

The Context carries the state of the current normalization run: the frontend request (with its TypoScript) and the ContentObjectRenderer of the originating DataProcessor, plus the processor options — the built-in RichtextParser uses the ContentObjectRenderer to parse values through lib.parseFunc_RTE.

Register the service 

In your extension's Configuration/Services.yaml:

services:
  MyVendor\MyExtension\FieldTransformer\ColorNameTransformer:
    tags: ['nb_headless.field_value_transformer']
Copied!

The chain asks every tagged transformer; the first supports() === true wins, and only strings pass through it (arrays and objects go to the normalizer chain instead).

Built-in transformers 

Transformer Applies to Output
PasswordBlanker Password fields "" — hashes never reach the client
RichtextParser Text fields with richtext enabled HTML via lib.parseFunc_RTE

Render containers 

This guide shows how to output EXT:container (b13/container) elements with this extension — the container's columns as JSON alongside or inside the container's own data.

Prerequisites 

  • EXT:container installed (composer require b13/container)
  • Container Content Types defined (e.g. the b13_2_columns_container used below, with child columns colPos 201 and 202)

b13/container is a suggested dependency — the extension works without it; the nb-container-json processor only loads when EXT:container is present.

TypoScript setup 

Variant 1: left/right parallel to data 

lib.content.select.where = colPos NOT IN (201, 202)

tt_content.b13_2_columns_container =< lib.contentElement
tt_content.b13_2_columns_container {
    fields {
        left = TEXT
        left {
            dataProcessing {
                10 = nb-container-json
                10 {
                    colPos = 201
                    as = left
                }
            }
        }
        right = TEXT
        right {
            dataProcessing {
                10 = nb-container-json
                10 {
                    colPos = 202
                    as = right
                }
            }
        }
    }
}
Copied!

Result: the container element carries left/right next to data:

{
    "id": 5,
    "type": "b13_2_columns_container",
    "colPos": 0,
    "left": [ { "id": 6, "type": "vendor_text", "data": { "...": "..." } } ],
    "right": [ { "id": 7, "type": "vendor_image", "data": { "...": "..." } } ]
}
Copied!

Variant 2: left/right inside data 

lib.content.select.where = colPos NOT IN (201, 202)

tt_content.b13_2_columns_container.fields.data.dataProcessing.10 {
    dataProcessing {
        10 = nb-container-json
        10 {
            colPos = 201
            as = left
        }

        20 = nb-container-json
        20 {
            colPos = 202
            as = right
        }
    }
}
Copied!

This is the sub data processor pattern from Add sub data processors — the columns land as keys inside data.

How it works 

nb-container-json (ContainerJsonDataProcessor) fetches the container's children for the given colPos via b13/container's ContainerProcessor and renders each child through the same conversion as nb-content-blocks-json — field identifiers as keys, same JSON contract.

Both variants are covered end-to-end by Tests/Functional/Frontend/ContentBlocksJsonResponseTest.php.

Container as Content Block (variant 2): keep your own fields 

When the container itself is a Content Block (variant 2), do not register it with Registry::configureContainer(): b13/container would overwrite the types[<cType>]['showitem'] of the Content Block, and the resolved record (and thereby data) would lose the Content Block's own fields. Write the containerConfiguration directly into TCA instead — this keeps the Content Block's showitem while b13/container still recognizes the container:

$configuration = new \B13\Container\Tca\ContainerConfiguration(
    'vendor_mycontainer',
    'My Container',
    '',
    [[['name' => 'main', 'colPos' => 201]]]
);
$GLOBALS['TCA']['tt_content']['containerConfiguration']['vendor_mycontainer']
    = $configuration->toArray();
Copied!

The colPos exclusion matters 

lib.content.select.where = colPos NOT IN (201, 202) keeps the container children out of the regular page content query. Without it, children appear twice: once in the page's content array and once in their column.

Options 

The processor accepts colPos (container column to fetch, required) and as (key of the children array). Children are rendered through their own tt_content.<CType> mapping, so their conversion options (processor options like options.processing or options.dateTimeFormat) are configured there, not on nb-container-json.

If the container cannot be built (e.g. the record is hidden), as is an empty list. Children without a renderedContent key — e.g. with b13's skipRenderingChildContent and no substitute data processor — become null entries.

Reference 

Lookup pages for the JSON output contract and the processors' options.

JSON contract 

This page documents the exact output shape of the conversion per field type. The contract is frozen by characterization tests (ContentBlocksJsonDataProcessorCharTest — they assert the complete JSON per fixture record); changes are deliberate decisions, not accidents.

Global rules 

  • Keys are the Content Block field identifiers (my_field), mapped from the database columns (tx_myext_my_field). Columns without a Content Block field definition pass through with their column name.
  • Order: keys are alphabetically sorted (ksort) — inside data, inside nested collections, everywhere.
  • System fields are stripped: uid, pid, colPos, CType, foreign_table_parent_uid, tx_container_parent.
  • Never break the response: unknown value types become null with a debug log entry; missing files produce __errorMessage entries (see below).
  • The id/type/colPos wrapper around data is built by EXT:headless, not by this extension.

Field types 

Content Block type JSON shape
Text string, unchanged
Textarea string, unchanged
Richtext (Text with enableRichtext) HTML string via lib.parseFunc_RTE
Number int (or float), 0/null when empty
DateTime W3C string 2023-10-20T14:08:34+00:00 (configurable via options.dateTimeFormat), null when empty
Select selected value(s): string or array of strings, "" when empty
Password "" — always blanked, hashes never leave the system
Email / Color / Slug string
Json parsed array (null when the column is empty)
Link link object, see below (null when empty)
File (oneToOne) file object, see below
File (oneToMany) array of file objects
Folder array of storage-absolute folder paths, e.g. ["/fileadmin/my-folder/"]
Category array of {uid, pid, title} (reduced shape — deliberate, see design record)
Collection array of item objects, each fully converted (identifier keys, sorted)
Relation resolved records, fully converted per target table
FlexForm parsed FlexForm values as array
Checkbox null + debug log — the Core Record API resolves checkboxes to booleans, and booleans are not part of the frozen contract. Register a custom normalizer if you need checkbox values.
unknown types null + debug log entry

File object 

{
    "id": 1,
    "alt": "",
    "title": "",
    "publicUrl": "https://example.com/fileadmin/image.jpg",
    "thumbnails": {
        "mobile": "https://example.com/fileadmin/_processed_/…jpg",
        "desktop": "https://example.com/fileadmin/_processed_/…jpg"
    }
}
Copied!
  • id is the sys_file_reference uid.
  • publicUrl is absolute and respects manual Backend crops.
  • thumbnails exists only when image variants are defined for the field — see Define image variants.
  • A missing/deleted file turns the whole field into {"__errorMessage": "…"}.

Category shape rationale 

Categories use the reduced {uid, pid, title} shape for historical contract reasons (the production consumer depends on it). If you need more fields, register a custom normalizer that claims LazyRecordCollection of sys_category records with a higher priority.

Collections and relations 

Each collection item is converted with the full pipeline of its own table: field identifiers as keys, system fields stripped, values normalized. Relation targets are resolved via the Core Schema API (ActiveRelation), so the correct sub-schema (per record type) applies.

Normalizers and transformers 

This page lists the built-in conversion services, their interfaces and their DI tags — the lookup companion to Architecture.

Normalizer chain 

Normalizers implement NormalizerInterface and are registered with the DI tag nb_headless.normalizer. The chain consults them in order (tag priority, highest first); the first supports() === true wins. If none matches, the UnknownTypeNormalizer emits null and logs the type.

interface NormalizerInterface
{
    public function supports(mixed $value, Context $context): bool;
    public function normalize(mixed $value, Context $context): mixed;
}
Copied!

Built-in normalizers (Classes/Normalization/Normalizer/):

Normalizer Claims Output
ScalarNormalizer null, int, string, plain arrays passthrough (arrays recurse via the chain)
DateTimeNormalizer DateTimeInterface formatted string, options.dateTimeFormat (default W3C)
FlexFormNormalizer FlexFormFieldValues parsed array
TypolinkNormalizer TypolinkParameter link object; null when empty; __errorMessage shape when unresolvable
RecordNormalizer Record full record conversion via Context::buildRecord()
RecordCollectionNormalizer LazyRecordCollection array; sys_category → reduced uid/pid/title, everything else per-record recursion
FileReferenceNormalizer FileReference, LazyFileReferenceCollection file object incl. crop-aware publicUrl and declarative thumbnails
FolderCollectionNormalizer LazyFolderCollection array of storage-absolute paths
UnknownTypeNormalizer everything (fallback, not tagged) null + debug log

Field value transformer chain 

Transformers implement FieldValueTransformerInterface and are registered with the DI tag nb_headless.field_value_transformer. They shape string values based on the field's Schema API type before the value reaches the JSON. First supports() === true wins.

interface FieldValueTransformerInterface
{
    public function supports(FieldTypeInterface $field): bool;
    public function transform(string $value, FieldTypeInterface $field, Context $context): string;
}
Copied!

Built-in transformers (Classes/FieldTransformer/String/):

Transformer Applies to Output
PasswordBlanker Password fields ""
RichtextParser Text fields with richtext enabled HTML via parseFunc($value, null, '< lib.parseFunc_RTE')

Context 

Normalization/Context carries the per-run state:

Member Purpose
getTcaSchema() current table schema (sub-schema per record type)
getRequest() PSR-7 request of the originating DataProcessor (may be null in CLI/unit contexts)
getContentObjectRenderer() the processor's ContentObjectRenderer (may be null in CLI/unit contexts)
getOptions() / getOption() TypoScript options. of the processor
getFileProcessingForCurrentField() image variant definitions of the field being normalized
getChain() the normalizer chain — for recursion without circular DI
buildRecord() full record conversion — for nested records
getEventDispatcher() PSR-14 dispatcher (deprecated event)

Registering your own 

See the how-to guides: register a custom normalizer and register a field value transformer.

Processor options 

This page lists the TypoScript options of the two data processors. Look them up here; for the surrounding pipeline see Architecture.

nb-content-blocks-json 

Used as dataProcessing.10 = nb-content-blocks-json (wired by the Site Set's lib.contentBlock):

Option Type Default Description
as string data key of the built array in the processor result
dataProcessing. array sub data processors, results merged into data — see Add sub data processors
options.processing.<field>.<variant> string image variant override per field identifier; merges over headless.yaml (TypoScript wins) — see Define image variants
options.dateTimeFormat string DATE_W3C format for DateTime fields (PHP date format, e.g. U for timestamps)

Example:

tt_content.vendor_myblock.fields.data.dataProcessing.10 {
    as = data
    options {
        dateTimeFormat = Y-m-d
        processing {
            image {
                desktop = width=1600c,fileExtension=webp
            }
        }
    }
    dataProcessing {
        10 = menu
        10 {
            levels = 2
            as = navigation
        }
    }
}
Copied!

Processing strings use the ext:headless syntax: key=value pairs, comma-separated (width=883c,fileExtension=webp) — accepted keys are the TYPO3 image processing instructions (width, height, fileExtension, crop, additionalParameters, ...).

nb-container-json 

Used inside container Content Types (see Render containers):

Option Type Default Description
colPos int container column whose children are fetched (e.g. 201)
as string key for the children array

Children are rendered through their own tt_content.<CType> mapping — their conversion options (options.processing, options.dateTimeFormat) are therefore configured there, not on nb-container-json.

Site Set 

The extension ships the Site Set nb-headless-content-blocks/headless-content-blocks which replaces lib.contentBlock (originally defined by EXT:content_blocks) with a lib.contentElement clone with the processor wired. EXT:content_blocks auto-maps every Content Block with a frontend template onto lib.contentBlock, so no manual TypoScript is needed — see Getting started → How it works for the two cases that do need one. Include the set in your site's config.yaml:

dependencies:
  - nb-headless-content-blocks/headless-content-blocks
Copied!

Troubleshooting 

This page lists known failure modes when using the extension — each entry follows symptom → cause → fix. Problems with the extension's own test setup are covered in Testing troubleshooting; if your case is missing, open an issue.

A field is null in the JSON and the log mentions an unknown type 

Symptom: a field renders as null and the TYPO3 log contains a debug-level entry about an unconvertible or unknown value type.

Cause: the value reached the end of the normalizer chain without any normalizer claiming it. Unconvertible values become null by design (never break the whole response) — this includes booleans and floats, which do not occur in real Content Block records. Previously such values were dropped silently; now they are visible.

Fix: register a custom normalizer for the value type. If the field should not be in the response at all, remove it from the Content Block.

JSON keys are database columns (tx_myext_field) instead of identifiers 

Symptom: some keys in data are the prefixed column names instead of the Content Block field identifiers (my_field).

Cause: the column → identifier mapping only covers fields that are defined in the Content Block&#039;s config.yaml. Columns that TYPO3 or another extension added to the table (but that are not Content Block fields) pass through unmapped — the raw column name is the documented fallback.

Fix: none needed if the column is intentional (e.g. useExistingField entries are mapped when defined). Otherwise define the field in the Content Block YAML, or drop the column from the output with a field value transformer / event listener.

thumbnails are missing on image fields 

Symptom: my_image has id/alt/title/publicUrl but no thumbnails key, although a headless.yaml exists.

Cause — pick one:

  • The headless.yaml is not directly next to the Content Block's config.yaml (wrong folder or wrong file name).
  • The YAML structure is wrong: processing is keyed by field identifier (fields.<identifier>.processing.<variant>), not by column name.
  • The parsed headless.yaml is cached: after changes, clear the TYPO3 cache (ddev typo3 cache:flush in DDEV setups).
  • The field is not a File/FileReference field.

Fix: correct the file and clear the cache. See Define image variants for the schema and the TypoScript override that wins over headless.yaml.

JSON contains a __errorMessage key 

Symptom: the block's data (or a link object) contains an __errorMessage entry instead of the expected value.

Cause: a referenced file no longer exists (FileDoesNotExistException), or a typolink target could not be resolved (UnableToLinkException). In both cases the rest of the response stays intact on purpose — the error is reported in place instead of breaking the page.

Fix: repair the record — re-select the file in the Backend, or fix the link target. The message text names the affected record/target.

Password fields are empty strings 

Symptom: every Password field renders as "".

Cause: by design — PasswordBlanker blanks password values before they reach any headless client. See Normalizers and transformers.

Fix: none — expected behavior. Do not send password hashes to the frontend.

Keys inside data are alphabetically sorted 

Symptom: field order in the JSON differs from the order in the Content Block YAML.

Cause: by design — the output is ksorted. Consumers already rely on the sorted order; it is part of the frozen JSON contract.

Fix: none — expected behavior. Sort concerns belong to the frontend.

Rich text renders without the expected wrapping (<p> classes, etc.) 

Symptom: richtext fields come back as HTML, but without the site's parseFunc classes/wrappers.

Cause: richtext fields are rendered through ContentObjectRenderer::parseFunc($value, null, '< lib.parseFunc_RTE'). The wrapper classes come from your TypoScript lib.parseFunc_RTE, not from this extension. Since TYPO3 13.2 lib.parseFunc is always provided by ext:frontend, but site-specific classes require your site package's setup.

Fix: configure lib.parseFunc_RTE in your site package (typically via fluid_styled_content's richContentObject or your own setup).

A block renders "has no rendering definition!" instead of JSON 

Symptom: the block appears in the page JSON, but instead of a data object it carries an error text like Content Element with uid "1" and type "vendor_myblock" has no rendering definition!.

Cause: the block has no TypoScript mapping. EXT:content_blocks auto-generates tt_content.<ctype> =< lib.contentBlock only for Content Blocks that ship a frontend template (templates/frontend.html). Template-less blocks and custom (non-Content-Block) content elements fall through to tt_content.default.

Fix: either give the block a templates/frontend.html (an empty one is enough — it is not rendered, the JSON output comes from the data processors) or map it yourself in your site package's TypoScript:

tt_content.vendor_myblock =< lib.contentBlock
Copied!

See How it works.

Container children do not render / render twice 

Symptom: the container's left/right columns are empty, or the children appear in the normal page content and in the column.

Cause: the container's TypoScript wiring. lib.content must exclude the container column positions, otherwise the children are rendered by the page content query as well.

Fix: add the exclusion and map the columns via nb-container-json — see Render containers.

Contributing 

Information for people working on the extension itself, not for integrators using it.

  • Testing troubleshooting — symptom → cause → fix for the extension's own test setup (DDEV, runTests.sh, act, functional tests)
  • How to contribute — workflow, tests, CGL and PHPStan in the repository

Testing troubleshooting 

Symptom → cause → fix for problems in this extension's own test setup (composer version switches, runTests.sh, GitHub Actions/act). Problems when using the extension are covered in Troubleshooting.

Unit tests fail with GeneralUtility::makeInstance of container-only services 

Symptom: unit tests (Build/Scripts/runTests.sh -s unit) fail resolving services like TcaSchemaFactory; the same code works in functional tests.

Cause: by design — unit tests run without any DI container. Code must tolerate missing container-only services (see AGENTS.md → Testing Gotchas).

Fix: make the service optional (nullable constructor argument + runtime fallback) as the core classes of this extension do.

Functional tests fail with Can not remove folder after running GitHub Actions locally 

Symptom: Build/Scripts/runTests.sh -s functional aborts with TYPO3TestingFrameworkCoreException: Can not remove folder.

Cause: act runs its job containers as root, leaving root-owned folders below .Build/public/typo3temp/var/tests/functional-* on the shared Docker daemon.

Fix: detect them with

find .Build/public/typo3temp/var/tests -maxdepth 1 -user root
Copied!

and have a user with root delete those folders (sudo rm -rf <folder>). More act gotchas (matrix parallelism, composer cache eviction): see AGENTS.md → Testing Gotchas and CONTRIBUTING.md.

Functional tests fail with Package "headless" depends on package "install" which does not exist. 

Symptom: a functional test that loads EXT:headless aborts during test instance creation with the message above (thrown by the testing framework's PackageCollection).

Cause: headless ≥ 5.0.0-rc2 ships no ext_emconf.php, so the testing framework resolves the composer dependencies to sort the classic-mode test instance packages. headless requires typo3/cms-install, but install is not among the default core extensions of a functional test instance (core, backend, frontend, extbase, fluid).

Fix: add install to $coreExtensionsToLoad of the test case (see ContentBlocksJsonResponseTest).

Design: Improve / Rewrite the ToArray conversion 

Status: IMPLEMENTED (all phases done), 2026-08-26 — see git history on feature/ImproveToArray. This is a historical design record; where wording differs from the code, the code wins.

Date: 2026-08-26 Scope: Classes/DataProcessing/ToArray/* (rewritten into Classes/Normalization/*), ContentBlocksJsonDataProcessor, ContainerJsonDataProcessor


1. Goal 

The core job of this extension: convert complex, rich TYPO3 domain objects (Record, FileReference, LazyRecordCollection, TypolinkParameter, ...) into plain PHP arrays so they can be serialized to JSON for headless responses.

Goals of the rewrite:

  1. Replaceable architecture instead of one giant switch (true) block.

    1. Use TYPO3 Core APIs (Schema API, Record API) instead of ContentBlocks internals where possible.
    2. Full control over the JSON contract (key mapping, field ordering, value shaping) — that is our product, not a side effect.
    3. Keep the PSR-14 extension point.
    4. Keep (or improve) the current 100 % test coverage as a safety net.

Non-goals:

  • No generic serializer framework (we only serialize TYPO3 domain objects).
  • No denormalization (JSON → objects).
  • We do not change the headless extension itself.

2. Current situation 

ContentBlocksJsonDataProcessor / ContainerJsonDataProcessor
 └─ RecordToArray(Record)                    → toArray(), strips system fields
     └─ ArrayRecursiveToArray(array)         → THE switch, per-value dispatch
         ├─ RecordToArray                    → recursion
         ├─ TypolinkParameterToArray
         ├─ FileReferenceToArray             → crop + public URL (ImageService)
         ├─ LazyFileReferenceCollectionToArray
         ├─ LazyFolderCollectionToArray
         ├─ LazyRecordCollectionToArray      → recursion per item
         └─ LazyRecordCollectionSysCategoryToArray
 └─ event: ModifyArrayRecursiveToArrayEvent  → PSR-14 per field/value
 └─ headless.php                             → per Content Block PHP post-processing
Copied!

Pain points:

# Pain Where
1 Monolithic switch (true) with instanceof chains; unknown types silently dropped (default case commented out) ArrayRecursiveToArray
2 Heavy dependency on ContentBlocks internals: TableDefinitionCollection, TcaFieldDefinition,  15 *FieldType instanceof checks everywhere
3 Manual relation-target parsing (foreign_table, allowed splitting) getTableNameByKey()
4 Version hacks property_exists(TcaFieldDefinition, 'parentTable') etc. — only needed because we construct ContentBlocks internals in tests tests
5 GeneralUtility::makeInstance() everywhere instead of DI → hard to mock, hidden dependencies all ToArray classes
6 String field handling (password blanking, richtext, passthrough) mixed into the same switch processStringField()
7 No way to configure output (absolute URLs? include system fields? per-block options) — hard-coded various

3. Research: What do TYPO3 13 / 14 and others offer? 

3.1 Core Record API (since 13.0, enhanced 13.3) 

RecordFactory::createResolvedRecordFromDatabaseRow() already converts raw DB rows into rich objects (Feature #103581, "Automatically transform TCA field values for record objects"):

  • relations → Record, LazyRecordCollection, FileReference, LazyFileReferenceCollection, Folder, LazyFolderCollection
  • datetime → DateTimeImmutable
  • link → TypolinkParameter
  • flex → FlexFormFieldValues
  • json → array

Insight: We already feed Record objects into our converter. The Record API does the object resolution — our job is only the array shaping. Nothing to gain from replacing this part; it is the right input.

3.2 Core Schema API (since 13.0!) — the important one 

TYPO3CMSCoreSchema* is the Core-native replacement for the ContentBlocks definitions we use:

ContentBlocks (now) Core Schema API (13+14)
TableDefinitionCollection::hasTable()/getTable() TcaSchemaFactory::has()/get()
TableDefinition->tcaFieldDefinitionCollection->hasField() TcaSchema::hasField()/getField()
TcaFieldDefinition->fieldType instanceof *FieldType Field::getType()TableColumnType enum (CategoryFieldType::getType() etc.)
manual foreign_table / allowed parsing RelationalFieldTypeInterface::getRelations(): ActiveRelation[]ActiveRelation::toTable()
ContentTypeResolver (CType → definition) TcaSchema::getSubSchema($cType) / hasSubSchema()
fieldType->getTca()['config'][...] Field::getConfiguration()

Key finding: The Schema API exists in both TYPO3 13 and 14. A rewrite based on it does not need a "v13 legacy / v14 only" split! The property_exists() version hacks exist only because we construct ContentBlocks internals — if we stop doing that, the hacks disappear.

3.3 Core PSR-14 events in the Record pipeline 

  • RecordCreationEvent — fired when a Record is created from a row. Tempting, but rejected as main mechanism: modifications there affect every consumer of the Record API (Fluid templates, other processors), not only the headless JSON output. Side effects we do not want.

3.4 TYPO3 14 specifics worth using 

  • lib.parseFunc / lib.parseFunc_RTE are always provided by ext:frontend (since 13.2, default removed from fluid_styled_content in 14 — Breaking-107438). Our richtext path can rely on them being present.
  • System Resource API (Feature-107537) — URL generation, not needed for FileReference public URLs, but relevant if we ever support EXT: resources.
  • No breaking changes in Schema/Record API that affect this rewrite.

3.5 What we still need ContentBlocks for 

Two things have no Core equivalent:

  1. Identifier mapping: ContentBlocks maps DB column tx_ext_my_field → Content Block field identifier my_field. Core TCA only knows the column name. Dropping this would change every consumer's JSON contract (breaking).

    1. headless.php: resolving the Content Block folder via ContentBlockRegistry::getContentBlockExtPath().

→ ContentBlocks stays a dependency, but only behind two narrow interfaces.

3.6 Real-world usage analysis (cms-netzbewegung-v5-2025, production site) 

Investigated the example project (/var/www/vhosts/cms-netzbewegung-v5-2025) and its JSON response (https://cms.netzbewegung-v5-2025.local/de/).

Setup:

  • TYPO3 13.4.32, content-blocks 1.6.1, headless 4.7.3, b13/container 3.1.12 → the main consumer is on v13. Confirms V2 (unified 13/14); a v14-only rewrite would not serve production.
  • Wiring lives in nb_frontend_api (TypoScript set): dataProcessing chains per block, nb-container-json for container columns (colPos 201/202 → left/right), extbase plugins as USER_INT (separate path).
  • JSON wrapper {"id":…,"type":"netzbewegung_headline","colPos":0,"data":{…}} is built by nb_frontend_api/headless around our processor output.

headless.php reality check: 13 headless.php files exist, all 13 do the same thing: generate responsive image variants (mobile, mobile2x, desktop, desktop2x, … webp, widths like 883c) via a static ThumbnailUtility (which internally abuses the Fluid ImageViewHelper!). Per block, per mode — dozens of hardcoded width configs.

Strongest finding for the rewrite: built-in, declarative image processing on File/FileReference fields would eliminate  all headless.php files. ext:headless FileUtility + ProcessingConfiguration already implements exactly this (TypoScript-style strings like width=883c,fileExtension=webp).

Contracts confirmed in production JSON (must not break):

  • Link fields: url, target, type, title, config, attr (+ null for empty links) — our TypolinkParameterToArray output
  • FileReference: id, alt, title, publicUrl (+ thumbnails added per block in headless.php)
  • Collections (items, buttons) with prefixField: false identifiers
  • Basics includes (Netzbewegung/Appearance), useExistingField (space_after_class), custom collection tables (tx_nb_buttons), prefixType: fulltx_nb_* columns
  • Alphabetically sorted keys in data (our ksort — consumers see it)
  • richtext bodytext rendered with custom parseFunc classes (p-text …)
  • processAdditionalDataProcessors is actively used (nb-job-cards-dynamic etc. via TypoScript dataProcessing.20 { as = cards })

3.7 How others do "object → array/JSON" 

Approach Example Takeaway for us
Normalizer registry Symfony Serializer (NormalizerInterface::supportsNormalization() + chaining) Extensible, each normalizer small & testable. No new dependency needed — we implement the pattern natively (symfony/serializer is not installed)
Resource/Transformer classes per type Laravel API Resources, League Fractal Explicit, discoverable; but one class per Content Block is too much boilerplate for us
Serializer with context Symfony groups / JMS Serializer The context idea is good: per-call options (absolute URLs, include system fields, field subsets)
JsonSerializable on wrappers various Elegant, but lazy — key mapping/sorting/event hooks get awkward; harder to debug
DataProcessors per type ext:headless itself (FilesProcessor, GalleryProcessor + FileUtility) Confirms the DataProcessor entry point; their ProcessingConfiguration string syntax is a nice idea for per-field image processing options

4. Options 

Option B — Schema-driven single converter 

One SchemaArrayConverter service: walks Record->toArray(), asks TcaSchema for each field's TableColumnType + configuration and converts in one (still large) method. Replaces instanceof chains with the enum, but stays monolithic and hard to extend by third parties.

Pros: compact. Cons: extensibility only via events; the switch survives.

Option C — Hook into Core RecordCreationEvent 

Modify values at Record creation so Record->toArray() is already JSON-ready.

Pros: least own code. Cons: global side effects on all Record consumers, no per-response shaping. Rejected.

Option D — JsonSerializable value wrappers 

Wrap rich objects in lazy JsonSerializable adapters; json_encode triggers conversion.

Pros: elegant. Cons: key mapping/k_sort/strip/events fight the pattern; hard stack traces. Rejected.

Cross-cutting decision — ContentBlocks usage 

Variant Description
C1 keep as-is current state, all pain points stay
C2 narrow behind interfaces Core Schema API for field metadata + relations; ContentBlocks only for identifier mapping + headless.php
C3 drop completely breaking JSON contract (column names instead of identifiers), headless.php gone — only for a future 3.0

Version strategy 

Variant Description
V1 v13 legacy + v14 new two code paths, double maintenance
V2 unified via Schema API Schema/Record API exist in 13+14 → one implementation, version hacks gone
V3 v14-only unnecessary once V2 is chosen; only interesting if we wanted 14-only Core features

5. Evaluation 

Criterion A + C2 + V2 B + C2 + V2 current
Extensibility (3rd-party normalizers) ++ o (events only) o (event only)
Testability ++
TYPO3 13 & 14, no hacks ++ ++
JSON contract stable ++ (covered by characterization tests) ++ ++
Migration risk
  • (phased)
Code size  +6 classes

6. Recommendation 

Option A (normalizer registry) + C2 (narrow ContentBlocks) + V2 (unified 13/14).

No split into v13/v14 versions — the Core APIs we need ship in both. This is also not a big-bang rewrite: phases below keep the suite green.


7. Proposed architecture 

Classes/
├── DataProcessing/
│   ├── ContentBlocksJsonDataProcessor.php   (entry, stays; slimmer)
│   └── ContainerJsonDataProcessor.php       (stays)
├── Normalization/
│   ├── NormalizerInterface.php              supports($value, Context): bool
│   ├── NormalizerChain.php                  DI-tagged 'nb_headless.normalizer'
│   ├── Context.php                          request, TcaSchema, options, eventDispatcher
│   ├── Normalizer/
│   │   ├── ScalarNormalizer.php
│   │   ├── DateTimeNormalizer.php
│   │   ├── RecordNormalizer.php             recursion, strips system fields
│   │   ├── RecordCollectionNormalizer.php   target schema via ActiveRelation
│   │   ├── CategoryCollectionNormalizer.php
│   │   ├── FileReferenceNormalizer.php      crop, publicUrl, declarative image variants (à la ext:headless ProcessingConfiguration)
│   │   ├── FileReferenceCollectionNormalizer.php
│   │   ├── FolderCollectionNormalizer.php
│   │   ├── FlexFormNormalizer.php
│   │   ├── TypolinkNormalizer.php
│   │   └── UnknownTypeNormalizer.php        explicit null + debug log
│   └── Event/
│       ├── BeforeFieldNormalizationEvent.php  (replaces ModifyArrayRecursiveToArrayEvent)
│       └── AfterRecordNormalizationEvent.php  (whole record, for headless.php alternatives)
├── FieldTransformer/
│   ├── FieldValueTransformerInterface.php
│   └── String/ PasswordBlanker, RichtextParser  (driven by TableColumnType + TCA config)
└── ContentBlocks/
    ├── IdentifierMapperInterface.php        uniqueIdentifier → identifier (passthrough fallback)
    └── HeadlessPhpProcessor.php             current headless.php include
Copied!

Details worth discussing:

  1. Context object carries: PSR-7 request, current TcaSchema, options (absoluteUrls, includeSystemFields, dateTimeFormat, per-processor config from TypoScript options.).

    1. FileReference output: today id/alt/title/publicUrl. Candidate: reuse or align with ext:headless FileUtility/ProcessingConfiguration so consumers get consistent URLs across processors. Contract change → decide.
    2. Events: keep ModifyArrayRecursiveToArrayEvent (deprecated alias) for one release; fire the new field-level event with the same payload.
    3. Services.yaml: normalizers as tagged services; NormalizerChain receives them via tagged iterator — no makeInstance left in the normalization path.

8. Migration plan (incremental, suite stays green) 

Phase Step Safety net
0 Freeze the contract: convert current functional tests into characterization tests (assert full JSON per fixture record) they must stay green until the end
1 Introduce Normalization/ namespace with NormalizerChain + first normalizers; ArrayRecursiveToArray internally delegates, still the public entry unit tests per normalizer
2 Replace ContentBlocks field-metadata lookups with TcaSchemaFactory (incl. ActiveRelation for relation targets); delete getTableNameByKey() parsing characterization tests
2b Add declarative image variant processing to FileReferenceNormalizer (config source per open question 6); migrate the 13 production headless.php files as pilot characterization tests
3 Move string shaping into FieldTransformer/*; move identifier mapping behind IdentifierMapperInterface unit tests
4 Wire DI (tagged services), remove makeInstance in the path; deprecate ModifyArrayRecursiveToArrayEvent
5 Cleanup: remove old classes, remove version hacks from tests, update AGENTS.md full suite + CGL + PHPStan on 13 & 14

9. Decisions (2026-08-26) 

Strategy: Option A (normalizer registry) + C2 (narrow ContentBlocks) + V2 (unified 13/14) — confirmed.

# Topic Decision
1 Key ordering Keep ksort — consumers already receive alphabetically sorted keys; no contract change
2 FileReference shape Keep id/alt/title/publicUrl; add opt-in declarative image variants (thumbnails) to replace handwritten headless.php generators
3 Unknown value types Emit null + debug log (no silent drops, never break the response)
4 headless.php API Optional 2nd parameter Context $contextfunction (array $data, Context $context) — backwards compatible
5 Context options TypoScript options. passthrough in v1, minimal set (absoluteUrls, dateTimeFormat)
6 Image-variant config source Both: headless.yaml in the Content Block (default, versioned with the block) + TypoScript override (per site); TypoScript wins on conflict

headless.yaml sketch (decision 2 + 6) 

# ContentBlocks/ContentElements/image/headless.yaml
fields:
  image:
    processing:
      mobile:     "width=883c,fileExtension=webp"
      mobile2x:   "width=1766c,fileExtension=webp"
      desktop:    "width=1564c,fileExtension=webp"
      desktop2x:  "width=3128c,fileExtension=webp"
Copied!
# per-site override (nb_frontend_api / site package)
tt_content.netzbewegung_image.fields.data.dataProcessing.10 {
    options {
        processing {
            image {
                desktop = width=1600c,fileExtension=webp
            }
        }
    }
}
Copied!

Processing strings reuse the ext:headless FileUtility/ProcessingConfiguration syntax, so output URLs match what headless consumers already get from FilesProcessor etc.

10. Remaining open points (non-blocking, decide during implementation) 

  • Exact Context option names and defaults (v1: absoluteUrls=true, dateTimeFormat=DateTimeInterface::W3C)
  • Whether UnknownTypeNormalizer log channel is nb_headless_content_blocks or the site's default core log
  • Deprecation window length for ModifyArrayRecursiveToArrayEvent (proposal: one minor release, keep firing it until then)

11. Additional goal: zero GeneralUtility::makeInstance() 

End state (after Phase 4/5): no makeInstance() anywhere in Classes/ — all dependencies via constructor injection. Normalizers never inject the chain (they recurse via Context::getChain()), so no circular DI problem. Intentional non-DI leftovers: the headless.php include (receives Context as parameter) and static utility calls (GeneralUtility::getFileAbsFileName() etc.).

Legacy: thumbnails via headless.php (ImageViewHelper) 

Status: SUPERSEDED by declarative image variants — for File fields of a Content Block, replace this pattern with headless.yaml. Kept as the historical reference for migrating existing blocks; production sites carry many copies of this file (13 on the site this extension was built for). One case is not yet replaceable: images inside Collection items — see the migration guide.

The pattern 

your_extension/ContentBlocks/ContentElements/your-content-block-element/headless.php with thumbnail generation through the Fluid ImageViewHelper:

<?php

use TYPO3\CMS\Fluid\ViewHelpers\Uri\ImageViewHelper;

$generateThumbnail = function (array $arguments): string {
    if (array_key_exists('absolute', $arguments) === false) {
        $arguments['absolute'] = true;
    }

    $imageViewHelper = new ImageViewHelper();

    foreach ($imageViewHelper->prepareArguments() as $argumentKey => $argumentDefinition) {
        if (array_key_exists($argumentKey, $arguments) === false) {
            $arguments[$argumentKey] = $argumentDefinition->getDefaultValue();
        }
    }

    $imageViewHelper->setArguments($arguments);

    return $imageViewHelper->initializeArgumentsAndRender();

};

foreach ($data['items'] ?? [] as $itemKey => $item) {

    if ($item['image']) {
        $image = $item['image'];

        $data['items'][$itemKey]['image']['thumbnails'] = [
            'mobile' => $generateThumbnail(['src' => $image['id'], 'treatIdAsReference' => true, 'width' => 320]),
            'desktop' => $generateThumbnail(['src' => $image['id'], 'treatIdAsReference' => true, 'width' => 800]),
        ];
    }
}

return $data;
Copied!

Why it was replaced 

  • Every Content Block repeated the same generator with different widths — pure duplication across site packages.
  • Driving a Fluid ViewHelper from PHP for URL generation is an abuse of the ViewHelper API (no DI, awkward argument filling, hidden dependencies).
  • Nothing about the variants was declarative or reviewable — widths hid in PHP closures.

The declarative replacement produces the same thumbnails map next to the frozen id/alt/title/publicUrl shape: Define image variants.

Archive 

This folder holds superseded documentation — records of how the extension got here, not how it works today. Where wording differs from the code, the code wins. Each archived page carries a status header pointing to its successor.