Skip to main content
Version: 2026.1

Template Examples

Worked examples that teach the LLM how to fill in your Twig templates. Two kinds of template need them:

  • Areabricks under templates/areas/<brick-id>/view.html.twig - surfaced by get_area_brick(brickId).
  • Document templates under templates/content/*.html.twig (and other top-level page/snippet/email/print templates) - surfaced by get_document_schema(documentId).

Examples are the primary signal for small LLMs (Haiku, GPT-4o-mini, etc.) - they pattern-match against a worked example more reliably than they reason from a formal grammar. For how the LLM consumes them and how the analyzer extracts the underlying editable-name set, see Architecture → Document Schema.

TL;DR

# 1. Discover the legal editable names for the template/brick
bin/console pimcore-agent:document-schema:names --brick=standard-teaser
bin/console pimcore-agent:document-schema:names content/portal.html.twig

# 2. Author JSON files in the sibling <stem>.examples/ folder

# 3. Validate
bin/console pimcore-agent:document-schema:lint

For automation, the bundle ships a coding-agent SKILL that performs the loop end-to-end.

Where fixtures live

A Twig template at <dir>/<stem>.html.twig gets fixtures in the sibling folder <dir>/<stem>.examples/. Same convention for areabricks and document templates:

templates/areas/standard-teaser/        ← areabrick
├── view.html.twig
└── view.examples/
├── _meta.json ← optional LLM-targeted description
├── all-direct.json ← variant 1
└── mixed-types.json ← variant 2

templates/content/ ← document template
├── portal.html.twig
└── portal.examples/
├── _meta.json
└── two-slide-carousel.json

The linter scans configured template roots (pimcore_agent.examples.template_roots, defaults to %kernel.project_dir%/templates + %kernel.project_dir%/bundles) and pairs each *.examples/ folder with its sibling *.html.twig file.

File formats

_meta.json (optional)

{
"description": "LLM-targeted technical brief - replaces the brick's PHP getDescription() in MCP responses.",
"addressing": "Optional override of the default '<areablock>:<index>.<editable>' usage hint."
}

The PHP getDescription() is the human-friendly copy shown to editors in Pimcore Studio. The _meta.json description is the LLM-targeted version and replaces the PHP one in MCP responses when authored. So when you do author it:

  1. Include a brief "what is this brick" sentence - without it the LLM loses the conceptual framing PHP provides.
  2. Then add the technical detail the LLM needs (offsets, addressing quirks, dynamic-block-name warnings).

Skip _meta.json entirely when the PHP description is sufficient and the brick has no LLM-specific quirks. PHP flows through automatically. Most simple bricks need no _meta.json.

addressing overrides the universal addressing hint and is only needed for bricks with non-standard semantics - the runtime SKILL teaches the universal rules (areablock prefix + block prefix + <index> substitution) once, so don't repeat them per brick.

Variant files (<slug>.json)

{
"label": "all three cards as 'direct' (image + title + text + link)",
"description": "Optional - describes what THIS variant demonstrates.",
"values": {
"standardTeaserHeading": "Featured stories",
"type_0": "direct",
"image1": {"id": 99001, "alt": "First feature"},
"title1": "Story one",
"text1": "<p>Description one.</p>",
"link1": {"path": "/news/one", "text": "Read more"}
}
}
  • label and description are optional (filename slug is the default label).
  • values is required - keys are the persisted editable addresses (see Address rules).
  • Asset / object IDs are placeholders in the 99000+ range. The runtime LLM substitutes real IDs from project data.

CLI commands

pimcore-agent:document-schema:names

Dumps the editable-name universe - the legal name set any fixture must constrain itself to.

bin/console pimcore-agent:document-schema:names --brick=standard-teaser
bin/console pimcore-agent:document-schema:names content/portal.html.twig

Output is JSON:

{
"editables": { "standardTeaserHeading": "input", "image<index>": "image", "type_<index>": "select", … },
"blocks": [],
"areablocks": []
}

editables is a single map of name => type. This combines the AST analyzer's view + Pimcore's editable registry + the dialog-box extractor (for PHP-side editables on bricks that implement EditableDialogBoxInterface).

pimcore-agent:document-schema:lint

Cross-checks every key in every fixture against the analyzer's name set:

bin/console pimcore-agent:document-schema:lint            # errors fail (exit 1), warnings advisory
bin/console pimcore-agent:document-schema:lint --strict # warnings also fail
FindingSeverityCause
Fixture key not in name seterrorTypo, or stale fixture after a template change
Editable in name set with no fixture coveragewarningOptional gap
No *.examples/ folderwarningAdvisory; not every brick needs one

Address rules

The CLI's output is the legal name set. When you write fixture values, derive the keys from that set using one of the five patterns below. Anything else and the linter rejects the fixture.

CLI shapeWhat you write as a value keyExample
Bare nameUse as-is"headline": "..."
<index> placeholderSubstitute concrete integer"image_<index>""image_0": ..., "image_1": ...
Block-prefixed name B:<index>.fieldSubstitute integer + ALWAYS declare "B": ["0", "1", …] alongside"images": ["0", "1"] + "images:0.image": ...
Multi-<index> in one nameSame <index> token gets the SAME value across the name"heroGridBlock:0.heroGridImage0": ..., "heroGridBlock:1.heroGridImage1": ...
Areablock-prefixed (document templates only)Address with <areablock>:<index>.<editable> + declare the areablock instance list"content": [{"key": "wysiwyg", "type": "wysiwyg"}] + "content:0.content": "<p>…</p>"

Brick-relative vs. page-relative

  • Areabrick fixtures use brick-relative addresses. Do NOT include the areablock prefix - the runtime LLM applies it when placing the brick under a specific areablock entry.
  • Document-template fixtures use page-relative addresses. When the page declares pimcore_areablock('content'), editables inside are persisted as content:0.<editable>. Editables outside any areablock (e.g. pimcore_input('pageTitle')) are addressed bare.

Areablock instance entries

When a document fixture populates an areablock, declare the brick instances as a flat array:

{
"values": {
"content": [
{"key": "wysiwyg", "type": "wysiwyg"},
{"key": "headlines","type": "headlines"}
],
"content:0.content": "<p>First brick.</p>",
"content:1.headline": "Second brick"
}
}

Each entry has key, type, and optionally hidden (default false). The key is per-instance; type is the brick id.

Realistic value shapes

TypeShapeExample
input / textareastring"Welcome"
wysiwygHTML string"<p>Hello <strong>world</strong>.</p>"
checkboxtrue / falsetrue
numericnumber42
dateISO-8601 string"2026-05-15"
select / multiselectone (or array) of the call-site's store keys"news" / ["a","b"]
image{"id": <int>, "alt": "<text>"}{"id": 99001, "alt": "Hero"}
video{"type": "asset", "id": <int>} or {"type": "youtube", "id": "<youtube-id>"}-
pdf{"id": <int>}{"id": 99001}
link{"path": "<path>", "text": "<label>"}{"path": "/about", "text": "About"}
relation / renderlet{"type": "object"&#124;"asset"&#124;"document", "id": <int>}{"type": "object", "id": 99001}
relationsarray of the above[{"type": "object", "id": 99001}]
snippet{"id": <int>}{"id": 99001}
embedURL string"https://www.youtube.com/watch?v=…"
table2D array[["Header1","Header2"],["a","b"]]
blockiteration declaration ["0", "1", …]-
areablockarray of instance entries ({key, type, hidden?})-

Dialog-box editables (defined in PHP via getEditableDialogBoxConfiguration()) match one of the types above and surface in the name set just like template-defined editables.

Authoring discipline

The bundled SKILL enforces a small set of conventions worth keeping in mind:

  • Cap variants at 3 per brick. LLMs do worse with too many examples than with too few.
  • A variant is a different combination of active editables, not a different fill of the same editables. So featurette gets a video-variant and an image-variant; wysiwyg-with-images gets one variant (just different image counts isn't a variant boundary).
  • Same <index> placeholder = same value within one name. A name like heroGridBlock:<index>.heroGridImage<index> gets the same integer substituted in both positions.
  • Block sub-editables always come WITH the block declaration. Writing images:0.image=… requires "images": ["0"] in the same fixture so the iteration count is recorded.
  • Asset/object IDs are placeholders in the 99000+ range - never real project IDs.

Gotchas

0-based vs. 1-based offsets

Some templates use {% for t in 0..2 %} and pass suffix: t+1 to includes. Result: type_0 (0-based) coexists with image1 (1-based) in the same brick. The schema shows both type_<index> and image<index> but doesn't reveal the offset difference - the fixture must pair them correctly (type_0 with image1, type_1 with image2, …). Always read the template to confirm the offset.

Dynamic block names

pimcore_iterate_block(pimcore_block('contents_' ~ i)) (the embed brick) has two distinct iteration indices that can't be folded into a single <index> placeholder. The schema reports socialContent as a bare name even though it's persisted as contents_1:0.socialContent, contents_2:0.socialContent, etc.

Author the fixture with the real persisted addresses:

{
"values": {
"contents_1": ["0"],
"contents_1:0.socialContent": "https://www.instagram.com/p/EXAMPLE_1/",
"contents_2": ["0"],
"contents_2:0.socialContent": "https://twitter.com/example/status/EXAMPLE_2"
}
}

The _meta.json description should warn the runtime LLM about this misleading shape so it doesn't try to write socialContent at the top level.

Nested blocks (block inside a block)

A template can declare a block whose sub-editables are themselves blocks - for example templates/includes/footer.html.twig:

{% for i in pimcore_iterate_block(pimcore_block('mainlinkblock')) %}
{{ pimcore_input('mainBlockTitle') }}
{% for j in pimcore_iterate_block(pimcore_block('linkblock')) %}
{{ pimcore_link('myLink') }}
{% endfor %}
{% endfor %}

The analyzer reports the inner block as a flat editable type rather than a fully-qualified address:

{
"editables": {
"mainlinkblock:<index>.mainBlockTitle": "input",
"mainlinkblock:<index>.linkblock": "block",
"linkblock:<index>.myLink": "link"
},
"blocks": ["mainlinkblock", "linkblock"]
}

Author the fixture with two parallel flat block iterations plus the per-parent inner-block declaration:

{
"values": {
"mainlinkblock": ["0", "1"],
"mainlinkblock:0.mainBlockTitle": "Company",
"mainlinkblock:0.linkblock": ["0", "1"],
"mainlinkblock:1.mainBlockTitle": "Support",
"mainlinkblock:1.linkblock": ["0"],
"linkblock": ["0", "1"],
"linkblock:0.myLink": {"path": "/about", "text": "About"},
"linkblock:1.myLink": {"path": "/team", "text": "Team"}
}
}

The linter's prefix walker descends through block patterns in editables, not through flat names in blocks - so the fully-nested form mainlinkblock:0.linkblock:0.myLink is rejected. Pair the parents and children at the top level.

Tricky select stores

When a template writes pimcore_select('mode', {store: [["a","a"],["b","b"]]}), the fixture value MUST be one of the literal keys. Some stores use integer keys (pimcore_select('slides', {store: [[1,1],[2,2]]})) - write them as strings: "slides": "2". Read the template to confirm the store contents.

The coding-agent SKILL

The bundle ships skills/template-examples/SKILL.md - a workflow file for coding agents (Claude Code, Cursor, Copilot, etc.). When you ask your agent to "add an example for the standard-teaser brick" or "refresh examples after updating the featurette template," it follows the recipe in that file: discover the legal name set, identify variants, author JSON, run the linter.

Reference it from the project's CLAUDE.md (or equivalent) so the agent finds it:

| Work area | Skill |
|---|---|
| Refresh JSON example fixtures consumed by the Pimcore Agent Bundle | dev/pimcore/pimcore-agent-bundle/skills/template-examples/SKILL.md |

The SKILL contains worked examples (standard-teaser, wysiwyg-with-images, embed, …) and is kept in sync with this documentation.

CI integration

Add the linter to CI so fixtures stay in lockstep with their templates:

bin/console pimcore-agent:document-schema:lint
# Exits 1 on any error (typo'd or stale fixture key).
# Add --strict to also fail on warnings.

Non-strict by default is intentional: warnings flag opportunities, errors flag breakage. Project teams flip to strict once every brick they care about has coverage.

When a brick lives in a third-party bundle

Fixtures must live alongside the template, which means in the vendor bundle's path. If you don't own the bundle, you can't currently add fixtures for its bricks - the linter warns for them and the LLM falls back to the flat editable list without examples. Project-local overlay support for vendor templates is a known gap.