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)
Define image variants — responsive thumbnails per field via headless.yaml, with per-site TypoScript overrides
Testing troubleshooting — symptom → cause → fix for the extension's own test setup (contributors)
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.
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:
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
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.
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
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.
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).
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:
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:
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.processing — TypoScript wins on conflict, headless.yaml variants without a TypoScript counterpart stay:
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:
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):
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:
The event keeps firing with its original payload until the next minor release; listeners continue to work unchanged until then.
The listener
<?phpdeclare(strict_types=1);
namespaceMyVendor\MyExtension\EventListener;
useNetzbewegung\NbHeadlessContentBlocks\Event\ModifyArrayRecursiveToArrayEvent;
useTYPO3\CMS\Core\Attribute\AsEventListener;
#[AsEventListener]finalclassMyCustomListener{
publicfunction__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 fieldsif (
$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:
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:
The file receives the fully built data array and returns the modified one:
<?phpdeclare(strict_types=1);
// $data: the complete JSON array of this block, field identifiers as keysforeach ($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 outputunset($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
<?phpdeclare(strict_types=1);
namespaceMyVendor\MyExtension\Normalization;
useNetzbewegung\NbHeadlessContentBlocks\Normalization\Context;
useNetzbewegung\NbHeadlessContentBlocks\Normalization\NormalizerInterface;
useTYPO3\CMS\Core\Resource\FileReference;
finalclassSquareThumbnailNormalizerimplementsNormalizerInterface{
publicfunctionsupports(mixed $value, Context $context): bool{
// claim only what you really want to shapereturn $value instanceof FileReference && ($context->getOption('square') === true);
}
publicfunctionnormalize(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.
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.
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.
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.
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:
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:
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 — the exact output shape per field type, frozen by characterization tests
Processor options — TypoScript options of nb-content-blocks-json and nb-container-json
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.
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.
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.
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.
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:
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 nullby 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'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:
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.
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:
Replaceable architecture instead of one giant switch (true) block.
Use TYPO3 Core APIs (Schema API, Record API) instead of ContentBlocks internals where possible.
Full control over the JSON contract (key mapping, field ordering, value shaping) — that is our product, not a side effect.
Keep the PSR-14 extension point.
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
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"):
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:
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:
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).
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: full → tx_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 })
Field-level shaping (password blanking, richtext parseFunc) becomes a separate FieldValueTransformer phase driven by Schema field type + config — no longer mixed into the type dispatch.
Pros: testable units, open for site-packages to register own normalizers (the actual headless use case), no version hacks. Cons: more classes ( 12), slight indirection.
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
Context object carries: PSR-7 request, current TcaSchema, options (absoluteUrls, includeSystemFields, dateTimeFormat, per-processor config from TypoScript options.).
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.
Events: keep ModifyArrayRecursiveToArrayEvent (deprecated alias) for one release; fire the new field-level event with the same payload.
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
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:
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.
Reference to the headline
Copy and freely share the link
This link target has no permanent anchor assigned.The link below can be used, but is prone to change if the page gets moved.