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 byget_area_brick(brickId). - Document templates under
templates/content/*.html.twig(and other top-level page/snippet/email/print templates) - surfaced byget_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:
- Include a brief "what is this brick" sentence - without it the LLM loses the conceptual framing PHP provides.
- 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"}
}
}
labelanddescriptionare optional (filename slug is the default label).valuesis 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
| Finding | Severity | Cause |
|---|---|---|
| Fixture key not in name set | error | Typo, or stale fixture after a template change |
| Editable in name set with no fixture coverage | warning | Optional gap |
No *.examples/ folder | warning | Advisory; 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 shape | What you write as a value key | Example |
|---|---|---|
| Bare name | Use as-is | "headline": "..." |
<index> placeholder | Substitute concrete integer | "image_<index>" → "image_0": ..., "image_1": ... |
Block-prefixed name B:<index>.field | Substitute integer + ALWAYS declare "B": ["0", "1", …] alongside | "images": ["0", "1"] + "images:0.image": ... |
Multi-<index> in one name | Same <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 ascontent: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
| Type | Shape | Example |
|---|---|---|
input / textarea | string | "Welcome" |
wysiwyg | HTML string | "<p>Hello <strong>world</strong>.</p>" |
checkbox | true / false | true |
numeric | number | 42 |
date | ISO-8601 string | "2026-05-15" |
select / multiselect | one (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"|"asset"|"document", "id": <int>} | {"type": "object", "id": 99001} |
relations | array of the above | [{"type": "object", "id": 99001}] |
snippet | {"id": <int>} | {"id": 99001} |
embed | URL string | "https://www.youtube.com/watch?v=…" |
table | 2D array | [["Header1","Header2"],["a","b"]] |
block | iteration declaration ["0", "1", …] | - |
areablock | array 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
featurettegets avideo-variantand animage-variant;wysiwyg-with-imagesgets one variant (just different image counts isn't a variant boundary). - Same
<index>placeholder = same value within one name. A name likeheroGridBlock:<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.