Twig helpers reference
Every function/macro a theme can call. Anything not listed is sandboxed
out — calls fail with a clear SecurityError.
Content rendering
field(name, type, default = null)
Renders an editable content block. type decides the partial used; see
field-types.md for the full list and per-type payload.
{{ field('intro', 'html') }}
{{ field('voor_na', 'gallery') }}
{{ field('hero', 'slideshow') }}
{{ field('contact_image', 'image') }}
{# `text` lets the theme own the surrounding tag (no wrapper rendered): #}
<h1 class="font-display ...">{{ field('hero_title', 'text') }}</h1>
In editor mode the output is wrapped in
<div class="cms-field" data-cms-field-name="..." data-cms-field-type="...">
so the SPA can find and edit it. In public mode the output is the inner
markup only — no wrapper.
Default value (text / html only). Pass a third argument and it is
rendered as the field's content whenever the page has no value for it —
key absent or stored-but-empty:
{{ field('hero_meta_operator', 'text', 'gsid · solo practice') }}
{{ field('contact_email', 'html', '<a href="mailto:x@y.nl">x@y.nl</a>') }}
Use this instead of the old field(...)|default('x') idiom. That filter
only fired when field() rendered to an empty string — but in the editor
the output is always wrapped in a non-empty <div class="cms-field">, so
|default never saw "empty" and the fallback silently vanished in the
admin. The native default renders the same markup on the public site,
in the editor (inside the editable marker), and in template preview, so
you no longer hand-roll a cms-field wrapper just to keep a default
editable.
The default is theme-authored markup and is emitted raw for html/text,
exactly like stored content — no |raw needed. The emptiness test matches
the old filter (=== ''), so a non-empty payload like <p></p> still wins
over the default. Defaults are ignored for non-inline types (image,
gallery, slideshow, embed, divider) — those use the editor's
placeholder affordance and cms_is_editing() gates instead.
cms_is_editing()
Returns true when the page is being rendered inside the admin editor's
iframe (i.e. field() is emitting editor markers). Returns false on
every public render.
Use it to widen "render only when populated" gates so editors can still
see and fill empty fields. Without it, conditionals like
{% if banner.src is defined %} hide the wrapper entirely on empty
pages, leaving the editor with nothing to click on.
{# Public site: hide the wrapper unless an image is set.
Editor mode: always render so the empty image can be filled. #}
{% set banner = page.getFields()['banner']|default({}) %}
{% if banner.src is defined or cms_is_editing() %}
<div class="banner-wrapper">
{{ field('banner', 'image') }}
</div>
{% endif %}
The function is read-only — it doesn't change behaviour, just lets themes branch on the render context. Empty image / gallery / slideshow / embed wrappers automatically get a labelled placeholder in the editor (e.g. Click ⚙ to choose an image) so editors have a visible affordance even before content lands.
cms_meta(name, value, placeholder = '', format = 'text')
Renders flat scalar page meta (title, metaDescription) with editor
markers. Use through the cms macro for cleanliness:
{% import '_shared/_macros.html.twig' as cms %}
<title>{{ cms.meta('title', page.title) }}</title>
<meta name="description" content="{{ cms.meta('metaDescription', page.metaDescription, 'Add a description…') }}">
placeholder is shown in the editor when the value is empty — visitors
never see it. format='html' allows inline tags in the rendered value
(rare; only for description previews where you want soft <strong>).
cms_gallery(name, mode = 'gallery', options = {})
One-call wrapper for gallery-shaped output. Modes:
'gallery'— plain grid; no JS.'carousel'— horizontal scroll with arrows + dot indicators.'lightbox'— clickable grid; click → fullscreen overlay.
{{ cms_gallery('voor_na') }}
{{ cms_gallery('voor_na', 'carousel') }}
{{ cms_gallery('voor_na', 'lightbox') }}
{{ cms_gallery('voor_na', 'carousel', {lightbox: true, aria_label: 'Voor & na'}) }}
carousel and lightbox modes auto-register their JS+CSS via
cms_module() — see per-page-modules.md.
A gallery may contain video, so cms_gallery(name, 'carousel') is also the
video slider — there is no separate one. Whether it shows images, videos or
both depends only on what the editor put in the field. Video slides load their
poster and nothing more until played, and a slide that scrolls out of view is
paused. See the gallery field type.
Video
cms_video(source, options = {})
Render an uploaded video. source is the stored /media/... path — the
same value an image field's src holds, and what the media library's
detail page gives you as a copy-ready snippet.
{# Inline in a text block — visitor presses play. #}
{{ cms_video('/media/ab/abcdefabcdefabcd.mp4') }}
{# Header background loop, with a still image on phones. #}
{{ cms_video(field.src, {autoplay: true, mobile: 'poster', class: 'hero__video'}) }}
Options
| Key | Default | Notes |
|---|---|---|
autoplay |
false |
Play on load. Forces muted, defaults loop on and controls off, and makes the frame --cover. |
muted |
false |
Mute. Implied by autoplay and cannot be overridden — see below. |
loop |
autoplay |
Restart on end. |
controls |
!autoplay |
Show player controls. |
cover |
autoplay |
Frame fills its container and crops (object-fit: cover) instead of letterboxing. |
mobile |
— | What phones get instead of the video: 'poster' (the generated frame) or a /media/... image path. Omitted plays the video there too. |
expandable |
false |
Adds a control that enlarges the video over the page. Ignored under autoplay. |
expand_label |
'Enlarge video' |
Accessible label for that control — override it on a non-English site. |
radius |
— | Passed straight through as --cms-video-radius, so the four-value shorthand works: '1.5rem 0 1.5rem 0'. |
alt |
media's alt | Alt text for the mobile still. |
fetchpriority |
none | high | low | auto on the mobile still. A header still is the LCP element on a phone: give it high. |
loading |
none | eager | lazy on the mobile still. Omitted means eager. |
sizes |
'100vw' |
sizes for the mobile still's srcset. |
preload |
'metadata' |
none | metadata | auto. Forced to none when mobile is set. |
class |
— | Extra classes on the frame. |
aria_label |
— | Accessible label on the player. |
Markup shape. Output is a frame wrapping a player:
<div class="cms-video-frame [cms-video-frame--cover] [your classes]">
<img class="cms-video-frame__mobile" …> <!-- only when `mobile` is set -->
<video class="cms-video" …><source …></video>
</div>
Size, aspect ratio, radius and background belong to the frame; video.js
builds its own box around the <video>, so those applied to the player would
fight it rather than contain it. That's also why class lands on the frame —
it's what you want to size and position.
The mobile still. A header loop on a phone is the worst version of the
trade: biggest file, smallest screen, least reliable connection, and autoplay
rules that vary by OS and battery state. {mobile: 'poster'} reuses the
poster frame generated at upload, so there is nothing extra to upload;
{mobile: '/media/...'} uses a different photo entirely. Both the image and
the video ship in the markup and CSS picks one at max-width: 767px. The
video is preload="none", its <source> carries media="(min-width: 768px)"
(autoplay overrides preload="none", so without it a phone still downloaded
the full-size master), and the player is never initialised while hidden, so
a phone doesn't pay for video it will never see. Rotating to landscape
initialises it properly rather than leaving an empty frame. A theme that moves
the 767px breakpoint in video.css has to know the source's breakpoint stays
at 768px.
With a still there is no poster attribute. A browser fetches a poster
even for a video that is display:none, so a phone downloaded the same frame
twice. Above the breakpoint the still sits under the video instead (the frame
is a stacking context and the still is at its bottom) and serves as the
poster until the clip plays over it. Its srcset runs to 1920 for that reason.
Both the poster and the mobile still are requested at a width (1920 and at
most 1280), because a poster is a frame cut from the clip and is therefore as
large as it: a 4K upload's poster is close to a megabyte at full size.
MediaController never upscales, so a smaller clip keeps its own size, and it
answers with AVIF or WebP where the browser takes one. The still carries a srcset
(480, 640, 960, 1280, 1920, cut off at the image's own width) with
sizes="100vw", so a phone takes the file that fits its screen. Pass a
narrower sizes when the frame is not full width.
On a header the still is the page's LCP element on a phone. Mark it:
{{ cms_video(src, {autoplay: true, mobile: 'poster', fetchpriority: 'high'}) }}
{{ field('hero_video', 'video', options: {fetchpriority: 'high'}) }}
A source that can't be resolved — empty field, deleted media, or a media row that isn't a video — renders nothing rather than erroring, so calling this on an unfilled field is safe.
There is no media-id form, on purpose. The media store's access model
is the opaque path: {xx}/{16 hex} is unguessable, files live outside
public/, and knowing the path is the permission to read the file. A
sequential id would be an enumerable handle onto the same files. Ids also
don't survive a reseed, while paths do.
Autoplay is always muted. Browsers refuse to autoplay a video with
sound, so {autoplay: true, muted: false} still renders muted. Without
that, a header loop would silently never start.
Sizes are picked by the player, not by you. The upload pipeline stores
downscaled 720p/1080p copies alongside the full-size file. They are not
emitted as several <source> elements — a browser doesn't pick a size from
those, it plays the first one it can decode, which would always be the
largest. Instead the helper emits one <source> (the full-size fallback,
which is what a visitor without JS gets) and hands the whole set to the
video module in a data attribute. The module chooses on layout width ×
device pixel ratio, and honours the browser's data-saver setting.
Registers the video module (video.js) automatically — see
per-page-modules.md.
Styling is yours, via CSS variables on .cms-video-frame:
| Variable | Default | Notes |
|---|---|---|
--cms-video-radius |
0 |
Takes the four-value shorthand, so per-corner is 1.5rem 0 1.5rem 0. |
--cms-video-width |
100% |
Frame width. |
--cms-video-max-width |
none |
Ceiling for the frame. |
--cms-video-aspect |
auto |
Aspect ratio when the source's own dimensions shouldn't win. |
--cms-video-fit |
contain |
object-fit. cover videos set this themselves. |
--cms-video-bg |
#000 |
Letterbox background. |
The 767px mobile breakpoint is a plain @media block in video.css — custom
properties can't be used inside a media query, so override that rule if your
theme needs a different one.
Enlarging is not fullscreen. {expandable: true} grows the video over the
page on a backdrop; clicking beside it or pressing Escape restores it. The
browser's fullscreen is deliberately not used — it hides the site, shows
browser chrome, and on iOS hands the clip to the system player. The control
only appears once the video module has claimed it, so it is never a dead
button.
Use this when the theme owns the video — a hardcoded path, an entity
accessor, a landing-page variable. When an editor should choose it, use
the video field type instead; it wraps this helper
and adds a cog dialog for the source and the playback options.
Output is is_safe: html.
Company and contact details
cms_company()
The website's contact and company details, as a flat map of strings. Set per website in the admin under Websites → Contact, so an editor changes the phone number without a deploy.
{% set c = cms_company() %}
{% if c.phone %}
<a href="{{ cms_company_href('phone') }}">{{ c.phone }}</a>
{% endif %}
Every key below is always present and always a string, blank when the
editor has not filled it in. That is deliberate: {% if c.phone %} is
enough, and you never need a defined() guard or a |default('').
| Key | Holds | Notes |
|---|---|---|
phone |
Phone number, as printed | Render this; link with cms_company_href('phone'). |
email |
Public e-mail address | Not the address mail is sent from — that is the website's mail-from setting. |
whatsapp |
WhatsApp number | Editors are asked for the country code, because wa.me needs it. |
street |
Street and house number | |
postal_code |
Postcode | |
city |
City | |
country |
Country, two-letter code | |
kvk |
Chamber of commerce number | Footer and terms pages. |
vat |
VAT / BTW number | |
facebook, instagram, linkedin, youtube |
Full profile URLs | Published as the organisation's sameAs in the JSON-LD. |
These are site-wide, which is exactly why they are not field()
calls: a field belongs to one page, and the header and footer render on
all of them. Putting the number in a field means editing it once per
page and getting it wrong on the page nobody checked.
The same values feed the Organization node in the page's JSON-LD
automatically. Nothing to do in the theme for that, and it means the
markup cannot drift from what the page shows.
cms_company_href(field)
The link target for the three fields that are never written the way they
have to be linked. Takes 'phone', 'whatsapp' or 'email'.
<a href="{{ cms_company_href('phone') }}">{{ cms_company().phone }}</a>
<a href="{{ cms_company_href('whatsapp') }}">WhatsApp ons</a>
<a href="{{ cms_company_href('email') }}">{{ cms_company().email }}</a>
| Stored | cms_company_href gives |
|---|---|
06 12 73 33 94 |
tel:0612733394 |
+31 (0)6 12 73 33 94 |
tel:+31612733394 |
+31 6 12 73 33 94 (whatsapp) |
https://wa.me/31612733394 |
info@example.nl |
mailto:info@example.nl |
Spaces, dashes and brackets are stripped. A bracketed trunk zero
(+31 (0)6 …) is dropped, because +3106… connects to nothing. A
leading 0 with no country code is left as typed: which country it
belongs to is not knowable from the number, and guessing produces a link
that silently dials the wrong one. If a theme needs the international
form, the editor types it.
Returns '' when the field is blank, so guard on the href itself and
skip the whole element:
{% set wa = cms_company_href('whatsapp') %}
{% if wa %}<a class="wa-footer" href="{{ wa }}" target="_blank" rel="noopener">WhatsApp</a>{% endif %}
For the floating chat button, use cms_whatsapp_button() below rather
than building one from this href: that one has admin settings.
cms_whatsapp_button()
The floating WhatsApp button, configured per website in the admin
(Websites → Contact → WhatsApp button): on/off, a number override (blank
means the company WhatsApp number), an opening message and a label per
language, and a country whitelist. Place it once, near the end of <body>,
and unconditionally:
{{ cms_whatsapp_button() }}
It renders nothing when the button is switched off or there is no number. It is on by default, so a theme that places it shows it as soon as the site has a WhatsApp number, which is what a hand-built float did.
Out of the box it is a green circle, bottom right, with the label beside
the icon on wider screens. For the theme's own look, ship
templates/whatsapp/button.html.twig; it gets:
| Variable | Is |
|---|---|
href |
The wa.me link, with ?text= for the opening message when there is one |
label |
The label for the page's language (default language's as fallback), '' when none |
message |
The opening message, unencoded, '' when none |
{# templates/whatsapp/button.html.twig #}
<a class="wa-float" href="{{ href }}" target="_blank" rel="noopener"
aria-label="{{ label ?: 'Stuur ons een bericht via WhatsApp' }}">
<svg …></svg>
{% if label %}<span>{{ label }}</span>{% endif %}
</a>
The country whitelist needs nothing from the override. Pages are cached
per URL, so the visitor's country can't be decided in the HTML: with a
whitelist the button is wrapped in a hidden element that a small script
reveals (display: contents, so the wrapper adds no box) once
/_cms/country says the visitor is in a listed country. A visitor whose
country can't be told doesn't see a whitelisted button.
Don't put these in a macro
The tempting shortcut is a theme macro file holding the number as a literal:
{# DON'T #}
{%- macro phone_href() -%}tel:+31612733394{%- endmacro -%}
Two things break. Changing the number needs a deploy, which is the whole
problem cms_company() exists to solve. And the page stops opening in
the inline editor: that renders a single {% block %}, which never runs
the template's top-level {% import %}, so the alias is null and the
macro call is a hard 500 — on the public site the same template renders
fine, so it looks like an editor bug rather than a theme one.
Macros are still the right tool for a parameterised markup component (a form row, a card). They are the wrong tool for data.
Google reviews
cms_reviews(options = {})
Renders the Google reviews widget for the current website. Reads
exclusively from the local mirror (GoogleReviewsSummary +
GoogleReview), populated by the daily reviews sync — it never calls the
Places API at render time. Renders nothing (empty string) when the
website has no Google Place id configured, or the first sync hasn't run
yet, so it is always safe to drop in unconditionally.
Two modes:
'grid'(default) — full panel: optional eyebrow + heading, a grid of review cards, and an aggregate footer. Use on a dedicated reviews section.'badge'— a compact inline chip (star + label +X.X / 5 (N reviews)) linking to the Google Maps listing. Use in headers, footers, nav, sidebars — anywhere a glanceable rating fits. Skips the per-review query.
{{ cms_reviews() }} {# full grid, 9 cards #}
{{ cms_reviews({ heading: 'Wat klanten zeggen', subheading: 'Reviews', limit: 6 }) }}
{{ cms_reviews({ mode: 'badge' }) }} {# compact rating chip #}
Options
| Key | Mode | Default | Notes |
|---|---|---|---|
mode |
both | 'grid' |
'grid' or 'badge' (anything else throws). |
limit |
grid | 9 |
Cards shown, clamped to 1..20. |
heading |
grid | — | <h2> above the grid; omitted when empty. |
subheading |
grid | — | Small eyebrow line above the heading. |
aria_label |
grid | 'Reviews' |
Accessible label for the section. |
badge_label |
badge | 'GOOGLE BEOORDELING' |
Text before the rating in badge mode. |
reviews_label, of_label, rating_label, based_on_label, certified_label |
both | Dutch defaults | Microcopy overrides — set for other locales. |
All copy defaults are Dutch; pass the label options to localise. The
function is allowlisted for sandboxed themes; output is is_safe: html.
Styling is via CSS variables on .cms-reviews / .cms-reviews-badge
(see cms-public/public/assets/reviews.css). To replace the markup
wholesale, drop a theme override at
@website/_partials/reviews.html.twig: it wins over the shared partial,
and the review/summary entity getters stay sandbox-reachable.
cms_reviews_photo(review)
Returns the URL the public site serves a review author's downloaded
profile photo from, or null when the review has no stored photo. Use it
inside a custom reviews override to render the avatar (and fall back to
initials when null):
{% set photo = cms_reviews_photo(review) %}
{% if photo %}
<img src="{{ photo }}" alt="" loading="lazy" width="48" height="48">
{% else %}
<span class="avatar-initial">{{ review.authorName|slice(0, 1)|upper }}</span>
{% endif %}
Linking
page_path(page) and page_url(page)
Generate links to a page. Pass the Page entity directly — no slug
juggling, the homepage handles itself.
{# in a nav loop where n.page is a Page entity #}
<a href="{{ page_path(n.page) }}">{{ n.page.title }}</a>
{# absolute URL (canonicals, og:url) #}
<link rel="canonical" href="{{ page_url(page) }}">
URL shape depends on whether the website has one or many locales:
- Single-locale website —
/<slug>(e.g./contact). Unchanged. - Multi-locale website —
/<locale>/<slug>for every page, including the default locale (e.g./nl/contact,/en/contact). Symmetric prefixes keep hreflang and canonical URLs clean.
You never need to think about this in templates — page_path() /
page_url() always emit the right shape for the active website.
Both accept null and render # for it, so they compose with
cms_page() (below) without a guard. Never hand-build these URLs: the
underlying Symfony route names are page (single-locale) and
page_localized (multi-locale), so a hardcoded path('page', ...)
breaks on multi-locale sites.
cms_page(slug)
Find a page by its slug and get it back in the locale being rendered.
This is the other half of page_path() — that one formats a page you
already hold, this one finds one. Use it for the structural links a
theme knows about by name (the quote form, the contact page) rather than
ones handed to it by nav_tree().
<a href="{{ page_path(cms_page('aanvraag')) }}">Container aanvragen</a>
{# the homepage is the empty slug #}
<a href="{{ page_path(cms_page('')) }}">Home</a>
The slug is always the page's DEFAULT-locale slug, in every locale.
On a NL/EN site the call above emits /nl/aanvraag when a Dutch visitor
reads it and /en/request-a-quote for an English one — the theme never
names the English slug. That's the point: the translated slug is
editable in the admin and belongs to the translator, so keying on it
would put your links at the mercy of a copy edit. Editing the default
locale's slug does break these links; that slug is the page's id, and
changing an id is a rename.
Returns null for an unknown or unpublished slug. Paired with
page_path() that renders as #, so a theme referencing a page the
site doesn't have yet ships an inert link rather than a 404 — a shared
theme can reference an optional page without breaking the sites that
skip it. Lookups are memoised per request, so pinning the same CTA in a
header, six sections and a footer costs one query.
Don't store resolved paths in a page field and read them back — that duplicates routing the CMS already owns and goes stale the moment an editor changes a slug.
page_translations(page)
For pages on multi-locale websites, returns the page's sibling translations as a list — useful for rendering a language switcher. Includes the current page so the switcher can highlight "you are here":
{# in <header> #}
{% set translations = page_translations(page) %}
{% if translations|length > 1 %}
<ul class="language-switcher">
{% for t in translations %}
<li>
{% if t.isCurrent %}
<strong>{{ t.locale|upper }}</strong>
{% else %}
<a href="{{ t.path }}" hreflang="{{ t.locale }}">{{ t.locale|upper }}</a>
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
Each entry: { locale, url, path, isCurrent, title }.
The list holds the locales the page genuinely exists in — the default
locale plus every published translation — not the website's configured
locales. An untranslated locale's URL does resolve (it serves the default
language), but offering it would promise "this page, in that language"
and hand back the same text, so it is left out. Drafts are excluded too.
On single-locale sites the list is always one entry, so the
|length > 1 guard above keeps the switcher off where it doesn't make
sense.
language_switch(page, color = 'currentColor', style = 'auto')
Renders a complete language switcher, for themes that don't need to own the markup. Returns an empty string when there's nothing to switch to, so it's safe to call unconditionally.
{{ language_switch(page) }}
{{ language_switch(page, color='white') }}
{{ language_switch(page, style='dropdown') }}
| Option | Default | Notes |
|---|---|---|
color |
'currentColor' |
Active pill background, and the select's border/text colour. |
style |
'auto' |
'auto' (pills ≤3 locales, dropdown ≥4), 'pills', 'dropdown'. |
Pills render as one connected group (.lang-switch wrapping
.lang-switch__pill, active item filled); dropdown mode emits a
.lang-switch__select that navigates on change. Same locale rules as
page_translations() — it's built on it.
A translation isn't a separate page — it's an overlay on the one you already have, edited from a language tab on that page's edit screen. See multi-locale.md for the model and what falls back when a locale is untranslated.
cms_language_alternates()
The same list as page_translations(), for the URL currently being
rendered rather than for a page you hand it. Takes no arguments:
{% set alternates = cms_language_alternates() %}
{% if alternates|length > 1 %}
<select data-go-on-change aria-label="Language">
{% for a in alternates %}
<option value="{{ a.path }}"{{ a.isCurrent ? ' selected' : '' }}>{{ a.locale|upper }}</option>
{% endfor %}
</select>
{% endif %}
data-go-on-change is one of the shared base's delegated listeners: it
navigates to the chosen option's value and ignores an empty one. Don't
reach for onchange= instead; inline handlers are off limits (see
csp_nonce()).
Each entry: { locale, url, path, isCurrent, title } — the shape
page_translations() returns, so one switcher partial can serve the
whole site. title is filled on page routes only; elsewhere it is ''.
Use this and not page_translations(page) for a switcher in a layout.
The page helper needs a Page, and the catalog, cart, checkout and
account routes have none, so a switcher built on it disappears the moment
a visitor enters the shop. A theme cannot fill that gap by hand either:
path() prefixes with the locale being rendered, and catalog slugs are
translated per locale, so there is no way to spell "this category, in
French" from a template.
What it returns per route:
| Route | Alternates |
|---|---|
page (/de/ueber-uns) |
the locales the page is really translated into, each own slug |
| category / product | every locale the website runs, with that locale's slug |
| cart, checkout, account, search, … | every locale the website runs; query string preserved |
| webhooks, media, sitemap, robots | nothing — no language dimension |
Empty on single-locale sites, outside a request (CLI, cache warm-up), and
whenever the slug in the URL matches no catalog entry. Safe to call
unconditionally; guard on |length > 1.
Per-product customer note
A product can ask the customer for a free-text note when they add it to the cart: allergies for a caterer, the text to engrave for an engraver. The question is set per product in the admin, so one field covers both.
{% if product.isCustomerNoteEnabled() %}
<label>
{{ product.getCustomerNoteLabelFor(cms_locale()) }}
<textarea name="customer_note" rows="3" maxlength="500"></textarea>
</label>
{% endif %}
The field name must be customer_note and it posts with the rest of the
add-to-cart form. getCustomerNoteLabelFor(locale) reads the same
per-locale translations map as name, falling back to the stored label
when that language has no translation, so the box is never unlabelled.
Reading it back: item.customerNote on both a cart line and an order
line, null when there is none.
{% for item in cart.items %}
{{ item.nameAtAdd }}
{% if item.customerNote %}<p class="note">{{ item.customerNote }}</p>{% endif %}
{% endfor %}
Three behaviours worth knowing before you build a cart UI around it:
- The note is part of a line's identity. The same product added twice with different notes stays two lines. Do not write a template that groups lines by product id.
- A note on a product that does not ask for one is dropped server side, so you cannot use the box as a general-purpose payload.
- Over 500 characters is refused, with a
cart_errorflash, rather than truncated.
cms_dir(locale = null)
Returns 'rtl' or 'ltr' for a locale. Called with no argument it uses
the locale being rendered (falling back to the website default), so a
theme can branch on direction without threading the locale through:
{% if cms_dir() == 'rtl' %}
{% include '_partials/_nav-rtl.html.twig' %}
{% endif %}
You do not need this for the <html> tag. The shared base already
emits it, from the same locale it puts in lang:
<html lang="ar" dir="rtl">
Right-to-left languages: ar, arc, ckb, dv, fa, he, ps,
sd, ug, ur, yi. Region suffixes are ignored (ar-EG is ar),
and anything unrecognised is ltr.
Why the attribute and not a CSS direction rule in the theme: Tailwind's
rtl: / ltr: variants match on [dir], so they are inert without it,
and the bidi algorithm needs the attribute to lay out mixed-direction
inline content correctly (a Latin brand name inside Arabic copy). Write
your layout with logical utilities (ms-, me-, ps-, pe-,
text-start, text-end, start-0) and it mirrors on its own.
The resolved locale is also in scope as {{ locale }} inside any block
you override in the base.
cms_t(key, params = {}, locale = null)
The copy cms-core's own templates are written in (cart, checkout, customer account, catalog search, FAQ, the order mails and invoices), in the language being rendered:
{{ cms_t('cart.subtotal') }} {# "Subtotaal" on a Dutch page #}
{{ cms_t('catalog.results.one', {count: 1, q: q}) }} {# fills %count% and %q% #}
{{ cms_t('cart.subtotal', {}, 'de') }} {# "Zwischensumme", whatever the page #}
Placeholders are written %name% in the string and keyed without the
percent signs in params.
Its use in a theme is narrow: an override of a module template that
wants to keep cms-core's wording, in every language cms-core ships,
without copying the strings over. It is not a translation system for the
theme's own copy. The keys are whatever cms-core's templates need, and
the only list of them is ModuleTranslator in cms-core.
Bundled languages: en, nl, de, fr. Lookup tries the exact locale,
then its two-letter prefix (nl-NL finds nl), then English, one key at
a time. A key found nowhere comes back as itself, so a typo shows on the
page as cart.subtotl rather than as a blank.
Without locale it uses the locale being rendered when the website runs
it, otherwise the website's default. cms_date()
reads the same packs.
cms_document(src, label = null, options = {})
Render a link to an uploaded document (PDF, or any other non-image /
non-video media in the library — they all live under the same
/media/{path} URL). Pass the stored media path; the media library's
detail page shows a copy-ready snippet for each file.
{{ cms_document('/media/02/0290f2f90813ceb0.pdf') }}
{# → <a href="/media/02/0290f2f90813ceb0.pdf" download>toelatingsvoorwaarden-antwerpen.pdf</a> #}
{{ cms_document(field.src, 'Download de brochure') }}
{{ cms_document(field.src, null, {target: '_blank', download: false}) }}
label defaults to the file's display filename when omitted (pass
null to keep the default while still setting options). When the
media row can't be resolved, the link still renders using the URL's
basename as text, so a stale reference degrades to a plain link rather
than vanishing.
Options
| Key | Default | Notes |
|---|---|---|
download |
true |
Emits the download attribute. Set false to open the file inline (e.g. view a PDF in-browser instead of downloading it). |
target |
— | Anchor target. '_blank' opens a new tab and auto-adds rel="noopener". |
class |
— | Class attribute for the <a>. |
To open a PDF in a new tab and view it inline (not download), combine both:
{{ cms_document(src, null, {target: '_blank', download: false}) }}.
Output is is_safe: html (href, label, and class are escaped).
cms_document_info(src)
Returns a document's metadata as a map, or null when the URL is empty,
isn't a media URL, or has no matching row — use it to build a custom link
showing file type and size:
{% set doc = cms_document_info(field.src) %}
{% if doc %}
<a href="{{ doc.url }}" download>
{{ doc.filename }}
<small>{{ doc.ext|upper }} · {{ doc.size_human }}</small>
</a>
{% endif %}
Each result: { url, filename, ext, mime, size, size_human } — size
is raw bytes, size_human is preformatted (e.g. 1.4 MB), ext is the
lowercase extension without the dot.
Navigation
nav_tree(menu = 'header_menu')
Returns one named menu as an ordered tree for the current website + request locale. A menu is identified by a handle string — the operator builds menus on the admin's Menus tab, where each item is either a link to a page or a free-form link (anchor, internal path, or external URL), and any item can nest under another one, page-linked or not.
The default handle is 'header_menu'. 'footer_menu' is the other
conventional one; any handle a theme references is offered to the
operator (the deploy scan reads literal nav_tree('…') calls out of
your templates, so use a string literal, not a variable).
Each node is:
{
label: string, # the item's label override, or the linked page's title
href: string, # ready to drop into href="" — page path, internal path, or URL
isCurrent: bool, # true when href exactly matches the current request path
newTab: bool, # the operator switched on "Open in a new tab" for this item
page: Page|null,# the linked Page for page items; null for free-form links
children: [ …recursive nodes… ]
}
Use node.href and node.label directly — they already account for
free-form links and label overrides. (node.page is only set for
page-linked items; don't reach for page_path(node.page) as the older
docs showed — a free-form link has no page.) Items are sorted by the
operator's order; page items pointing at unpublished pages are dropped.
<nav>
{% for n in nav_tree('header_menu') %}
<a href="{{ n.href }}"{{ nav_link_attrs(n) }} class="{{ n.isCurrent ? 'is-current' : '' }}">
{{ n.label }}
</a>
{% if n.children %}
<ul>
{% for c in n.children %}
<li><a href="{{ c.href }}"{{ nav_link_attrs(c) }}>{{ c.label }}</a></li>
{% endfor %}
</ul>
{% endif %}
{% endfor %}
</nav>
isCurrent is an exact path match on the active item only — there's
no ancestor/trail flag, so walk the tree yourself if you need a "current
section" highlight on parents. For a breadcrumb, don't: use
cms_breadcrumb() below, which knows the page's real ancestry.
nav_link_attrs(node)
The extra anchor attributes one nav_tree() node asks for. Print it
straight after the href, on every item, top level and children alike:
<a href="{{ n.href }}"{{ nav_link_attrs(n) }}>{{ n.label }}</a>
{# newTab on: <a href="/media/…/prijslijst.pdf" target="_blank" rel="noopener noreferrer"> #}
{# newTab off: <a href="/contact"> #}
When the operator switched on Open in a new tab for the item (on the
Menus tab: meant for a PDF, or any link that should not replace the
site), it returns target="_blank" rel="noopener noreferrer", with the
leading space. Otherwise it returns an empty string, so call it
unconditionally. It is marked HTML-safe, so no |raw.
Use it rather than testing n.newTab yourself. The rel is the part a
hand-written conditional forgets, and a menu item's URL is typed by an
editor and can point anywhere. A theme that never calls it still renders,
but ignores the switch: every item opens in the same tab.
Mind the deploy order. On a cms-public whose cms-core predates this helper, the sandbox refuses the call at compile time, and that takes down every template extending the layout that makes it, not just the one menu. Update cms-public before you ship a theme that calls it.
cms_breadcrumb() and cms_breadcrumb_items()
The trail from the site root down to whatever this page is showing.
cms_breadcrumb() prints ready-made markup; cms_breadcrumb_items()
hands you the data instead.
{{ cms_breadcrumb() }}
That is the whole integration. It works out where it is on its own, and each kind of page has its own ancestry:
| Page | Trail |
|---|---|
| A page | Home → its menu ancestors → the page |
| A category | Home → its parent chain → the category |
| A product | Home → the first category it is filed in, chain and all → the product |
| Anything else | Just Home, so nothing is rendered |
Pages use the menu hierarchy because pages are a flat list: nesting is set on the admin's Menus tab, not on the page (see Navigation). Categories use their real parent chain. That difference is the content model, not an inconsistency.
It renders nothing when the trail is only "Home", which is what you
want: drop the call in your layout once and the front page, the cart and
the login form stay clean, without a {% if %} around it.
Restyling it
Assume you will want it to look different, because everyone does. In increasing order of how much you take over:
1. CSS. The default markup uses single-class selectors and reads its
look from custom properties, so this is usually enough and never needs
!important:
.cms-breadcrumb {
--cms-breadcrumb-separator: '›';
--cms-breadcrumb-gap: 1rem;
--cms-breadcrumb-color: #64748b;
--cms-breadcrumb-current-color: #0f172a;
--cms-breadcrumb-font-size: .8125rem;
}
The markup it emits:
<nav class="cms-breadcrumb" aria-label="Kruimelpad">
<ol class="cms-breadcrumb__list">
<li class="cms-breadcrumb__item">
<a class="cms-breadcrumb__link" href="/">Home</a>
</li>
<li class="cms-breadcrumb__item cms-breadcrumb__item--current">
<span class="cms-breadcrumb__current" aria-current="page">Driewielers</span>
</li>
</ol>
</nav>
The separator is drawn with ::before, not written into the DOM, so
selecting the trail copies Home Driewielers rather than
Home / Driewielers, and a screen reader doesn't read the slashes out.
Keep that if you write your own.
2. Your own template. Want different elements, not just different
paint? Ship templates/_partials/breadcrumb.html.twig in your theme and
it is used instead of core's. You get items (see below) and a
with_styles flag you can ignore, since core's CSS is not emitted once
you have taken over.
3. The raw data. When even the template is in the way:
{% set crumbs = cms_breadcrumb_items() %}
{% if crumbs|length > 1 %}
<nav aria-label="Breadcrumb">
{% for c in crumbs %}
{% if c.current %}
<span aria-current="page">{{ c.label }}</span>
{% else %}
<a href="{{ c.href }}">{{ c.label }}</a> /
{% endif %}
{% endfor %}
</nav>
{% endif %}
Each item is { label: string, href: string, current: bool }. Home is
first, the current page is last, and current is true on that one only.
Note the |length > 1 guard: unlike cms_breadcrumb(), the raw list
always contains at least Home.
This is a separate thing from the BreadcrumbList structured data in
seo_json_ld(), which search engines read and visitors never see. You
do not have to keep the two in sync; both are generated for you.
Theme assets
theme_asset(relative_path)
Resolve a path relative to this theme's NFS version directory. Use for images, CSS, optional JS shipped in the theme tarball.
<link rel="stylesheet" href="{{ theme_asset('styles.css') }}">
<img src="{{ theme_asset('img/logo.svg') }}" alt="">
<script type="module" src="{{ theme_asset('app.js') }}" defer></script>
Resolves to a URL handled by cms-public/src/Controller/ThemeAssetsController.php,
which serves the file via X-Sendfile from
/data/cms/themes/<code>/<version>/<relative_path>.
Only files inside that version directory are served, and nothing under
its templates/. The path is resolved before it is checked, so
img/../templates/..., a .. that climbs out of the version directory
(into an older version of the theme, or another theme) and a symlink
that points out of it all get a 404, the same answer as a missing file.
Ship every file the theme references in its own tarball.
theme_asset() requires both a resolved website context AND a
non-null Website.themeVersion. A missing precondition raises a
RuntimeException instead of generating a 404'ing URL — deploy a
theme version before using theme_asset() in a template.
Content-Security-Policy
csp_nonce()
The request's CSP nonce, the value the policy expects on a script it is willing to run.
You almost never need this. Every <script> tag written in a
template, cms-core's own and every theme's, is stamped with the nonce
automatically at compile time by a Twig node visitor. Writing it by
hand is harmless (a tag that already has a nonce= is left alone), but
it is noise.
The one case that needs it is a tag assembled from variables, which the visitor cannot see:
{% set tag = '<script nonce="' ~ csp_nonce() ~ '">' ~ body ~ '</script>' %}
If you find yourself writing that, stop and ask whether the script can just be a literal tag instead. It almost always can.
What is deliberately not nonced: a <script> arriving through a
variable without this call, and anything inside stored page content.
Both are the shape an injected script takes, and both should fail once
the policy tightens.
Nothing can nonce an attribute, so inline on*= handlers have no
equivalent. Use the shared data attributes
(data-submit-on-change, data-go-on-change, data-confirm,
data-toggle) or an addEventListener in the theme's app.js. See
theme-authoring.md → hard rules.
Per-page modules
cms_module(name) and cms_modules()
Opt into a JS+CSS module on this page. Each registered module emits one
<link rel="stylesheet"> + one <script type="module"> from
_shared/base.html.twig's {% block javascripts %}. Files live in
cms-public/public/assets/<name>.{js,css}.
{# Anywhere in a page or partial: #}
{% do cms_module('lightbox') %}
{# In the base layout (already done in cms-core): #}
{% for m in cms_modules() %}
<link rel="stylesheet" href="{{ asset('assets/' ~ m ~ '.css') }}">
<script type="module" src="{{ asset('assets/' ~ m ~ '.js') }}" defer></script>
{% endfor %}
The shared partials _shared/_carousel.html.twig and _shared/_lightbox.html.twig
already call cms_module() themselves — themes don't need to register
them when they go through cms_gallery(). Same for the slideshow
field's partial.
See per-page-modules.md for adding new modules.
SEO / metadata
seo_json_ld(page)
Returns the auto-generated JSON-LD @graph for the page as an
array. The shared _partials/seo.html.twig calls it, prefers
page.seo.json_ld if the editor stored an override, otherwise emits
this. Themes don't usually call it directly.
seo_hreflang(page)
Renders <link rel="alternate" hreflang="..."> tags for every
translation of the page plus an x-default row pointing at the
default-locale variant. Drop one call into <head> and the page
gets correct cross-locale annotations for free.
{# in <head> #}
{{ seo_hreflang(page)|raw }}
Output (multi-locale page with NL + EN siblings, NL = default):
<link rel="alternate" hreflang="nl" href="https://example.com/nl/over-ons">
<link rel="alternate" hreflang="en" href="https://example.com/en/about-us">
<link rel="alternate" hreflang="x-default" href="https://example.com/nl/over-ons">
Returns an empty string when the page has no published siblings — a
single self-reference adds no SEO signal, so we skip it rather than
emit a lone tag. Single-locale sites also get an empty string. The
|raw filter is required because the helper produces HTML.
The same xhtml:link annotations are emitted in /sitemap-pages.xml
automatically — seo_hreflang() is the in-page complement.
Forms
cms-core ships server-rendered forms for contact pages, quote requests, newsletter signups, etc. This is a quick-reference list — see forms.md for the full end-to-end (template + admin config + protection stack + mail-from chain + what arrives in the inbox).
Only form_start takes the form id. Every other helper binds to the
open form automatically and takes the field name first; form_end,
form_protection, form_errors and form_status take no id at all.
Passing one shifts every following argument by a slot and throws.
{# Boundaries: form_start opens, form_end closes (and emits the
signed schema input with the field list and form id, HMAC-signed;
on a cacheable page the shared schema, which the `form` module swaps
for a per-visitor one, see forms.md "Forms on cached pages"). #}
{{ form_start('contact') }}
{{ form_end() }}
{# Required: emits hidden _csrf + invisible _hp honeypot.
CSRF + honeypot are always-on. The signed schema, time-gate,
and per-IP/per-form rate limiters live on the submit controller —
not opt-in here, always on. #}
{{ form_protection() }}
{# Label — text is required, attrs optional. #}
{{ form_label('name', 'Your name') }}
{{ form_label('name', 'Your name', {class: 'sr-only'}) }}
{# Input — type is positional ('text' | 'email' | 'tel' | 'url' |
'number' | 'date' | …). attrs accepts validation rules: required,
min, max, pattern (PCRE, anchored server-side). #}
{{ form_input('name') }}
{{ form_input('email', 'email', {required: true, placeholder: 'you@example.com'}) }}
{{ form_input('zip', 'text', {required: true, pattern: '\\d{4}\\s?[A-Z]{2}'}) }}
{# Textarea — attrs only. #}
{{ form_textarea('message', {rows: 6, required: true, max: 2000}) }}
{# Select — options is a positional list of {value, label} maps.
The schema enforces that the submitted value is in this list. #}
{{ form_select('topic', [
{value: 'general', label: 'General'},
{value: 'support', label: 'Support'},
], {required: true}) }}
{# Checkbox — value is positional (defaults to '1'). #}
{{ form_checkbox('consent', '1', {required: true}) }}
{# Radio — ONE call per radio option (helper renders one input). Loop
in the template for option lists. #}
{% for opt in [{value: 'starter', label: 'Starter'}, {value: 'pro', label: 'Pro'}] %}
{{ form_label('plan_' ~ opt.value, opt.label) }}
{{ form_radio('plan', opt.value) }}
{% endfor %}
{# File upload — requires form_start('id', {files: true}). Limits
(count, size, accepted types, retention) come from the per-form
admin config, not the template. See forms.md#file-uploads-attachments. #}
{{ form_file('photos', {required: true}) }}
{# License plate, needs the vehicle_lookup module. Renders the plate
input plus hidden <name>_brand / _model / _year / _country that the
field JS fills from the lookup, so the vehicle posts with the form.
Degrades to a plain text input where the module is off. #}
{{ form_licenseplate('license_plate') }}
{{ form_licenseplate('license_plate', {country: 'NL', placeholder: '12-AB-CD'}) }}
{# Submit — text is positional (defaults to 'Submit'). #}
{{ form_submit('Send') }}
{{ form_submit('Send', {class: 'btn btn--primary'}) }}
{# Field-bound + global errors. Both render only when there's a
flashed error from the previous submit. #}
{{ form_error('email') }}
{{ form_errors() }}
{# Success / error banner after submit. Returns 'success', 'error',
or null. #}
{% if form_status() == 'success' %}
<p>Thanks — we'll be in touch.</p>
{% endif %}
The renderer signs the field schema server-side; the submit controller rejects POSTs whose field set doesn't match what was rendered. Themes own the layout — no Symfony Form classes are exposed.
Never mark the plate lookup's output as required. form_licenseplate
fills brand/model/year from a registry call that can miss, time out, or
never happen at all when JavaScript is off, and in every one of those
cases the visitor still submits with those fields empty. They are also
browser-filled, so a visitor can edit them: good enough to route a quote
request, not evidence. Ask for anything you truly need as its own field.
See operators/modules/vehicle-lookup.md.
Landing-page templates — {{ vars.X }}
Templates under templates/landing/*.html.twig are rendered by the
landing-pages module instead of by the regular page renderer. Inside
them, an extra context variable vars holds the per-page variable
map (operator-typed strings):
<h1>Shopping in {{ vars.city }}</h1>
<p>The best coffee city has {{ vars.coffee_count }} shops.</p>
Also in scope: landing.slug, landing.dataset_name,
landing.meta_title, landing.meta_description. The regular page
variable is null on landing renders — you can't cms_meta() on
a landing page, the meta tags come from the dataset's pattern fields.
Designer-facing reference: operators/modules/landingpages.md.
(The link is to the operator doc because that's where the full
shape lives; the designer-side concern is which references the
scanner picks up — only plain vars.X and vars["X"]/vars['X'],
nothing dynamic.)
Customer auth forms — cms_register_form() / cms_login_form() / etc.
These are not built with the form_* helpers above. They're shipped
by the Customer module (when enabled) as opaque, themeable form
renderers — the controllers know about password hashing, verification
tokens, and the customer firewall. See
operators/modules/customer.md for the
list and the per-form template overrides themes can provide.
Module helpers
Each optional module contributes additional Twig functions, filters and
entity accessors via SandboxContributorInterface.
The allowlist is cluster-wide, not per website. It is built once from
every module compiled into cms-core, so these names are callable
everywhere; what the per-website toggle changes is the answer. On a site
without the blog, cms_articles() returns [] rather than raising a
sandbox error. Use
is_module_enabled() when you need to drop the
markup around the empty result too.
Each module's full configuration surface — what operators set in the admin, how dependencies work, what each helper returns — lives in the per-module pages under operators/modules/. What follows here is the theme-side API the Twig sandbox exposes when the module is enabled.
is_module_enabled(id)
Ask directly whether a module is on for the website being rendered:
{% if is_module_enabled('cart') %}
<a href="{{ path('cms_cart_show') }}">Winkelmandje ({{ cms_cart_count() }})</a>
{% endif %}
The module helpers gate internally too, so cms_articles() returns []
rather than exploding on a site without the blog. This function exists so
a theme can drop the surrounding markup as well: the badge, the nav
link, the empty <section> wrapper that would otherwise render around
nothing.
Ids are the ones in operators/modules/index.md:
blog, catalog, customer, cart, checkout, shipping,
discount, catalog_discount, landingpages, portfolio, faq,
newsletter, vehicle_lookup, vehicle_stock. An unknown id is simply false.
It answers the effective state, so a module whose dependency is missing reads as off, which is what the site actually does.
Blog module (blog)
All helpers return only visible articles (published AND publish date
passed) and return [] / null when the module is off — safe to call
unconditionally in a shared theme. opts accepts {limit, offset, sort}
where sort is one of newest (default) / oldest / title.
| Helper | Returns |
|---|---|
cms_articles(opts = {}) |
Visible articles on the website |
cms_article(slug) |
One visible article by slug, or null |
cms_recent_articles(limit = 5) |
Shortcut for the N newest articles (sidebars, "latest posts") |
cms_related_articles(article, limit = 3) |
Other articles sharing a tag, most-relevant first (or []) |
cms_articles_by_tag(tag_slug, opts = {}) |
Visible articles carrying the given tag slug |
cms_article_tags(article) |
The tags attached to an article (list of ArticleTag) |
Link to articles / tag archives with path() on the public routes:
path('cms_article_show', {slug: a.slug}),
path('cms_article_tag', {slug: t.slug}),
path('cms_articles_index').
Allowed entity getters (in addition to core's Page/Website/Media):
Article: id, slug, title, excerpt, body, featuredImage, author, isPublished, publishedAt, isVisible, seo, translations, getTranslated(field, locale), tags, createdAt, updatedAt.ArticleTag: id, slug, name, createdAt, updatedAt.
Reference: operators/modules/blog.md.
Catalog module (catalog)
| Helper | Returns |
|---|---|
cms_products(category_slug = null, opts = {}) |
List of published products, optionally scoped to one category |
cms_product(slug) |
One product by slug, or null |
cms_categories(opts = {}) |
One level of categories, relative to the page (see below) |
cms_featured_products(opts = {}) |
Shortcut for the featured-flagged products |
cms_tags(opts = {}) |
Tag list |
cms_products_by_tag(tag_slug, opts = {}) |
Products tagged with the given slug |
cms_search_form(opts = {}) |
Renders the catalog search form (default template, themeable) |
cms_search_results(opts = {}) |
Renders the current search-result set |
cms_price(product, mode = 'gross', locale = null) |
Pre-formatted price string (currency- and locale-aware) |
cms_product_image(image, params = {}) |
Public /media/... URL for a product/variant/category image ref |
cms_product_img(image, attrs = {}) |
A complete <img> tag for the same, with intrinsic width/height |
value|cms_money(currency, locale = null) (filter) |
Format an int/float minor-units value with the given currency |
cms_categories() returns ONE level
A bare call gives you the categories under wherever the visitor currently is: on a category page that is the current category's children, anywhere else it is the top-level categories.
{% for c in cms_categories() %}
<a href="{{ path('cms_category_show', {slug: c.slug}) }}">{{ c.name }}</a>
{% endfor %}
| Page | You get |
|---|---|
/products, home, a page |
The root categories |
/categories/scootmobielen |
Driewielers, Vierwielers, Vijfwielers |
/categories/driewielers |
Nothing: a leaf category has no children |
This is what you want for a nav or filter row. It used to return the
whole tree flattened, which meant a row rendered from it showed
Scootmobielen sitting next to its own child Driewielers as if the two
were equal choices.
Pair it with cms_breadcrumb() so a visitor who has descended can get
back up: the chips go down, the breadcrumb goes up.
A leaf category renders an empty row. If your design needs a filter row to always have something in it, check the length and fall back:
{% set kids = cms_categories() %}
{% for c in (kids|length ? kids : cms_categories({depth: 1})) %}
To ask for something other than "one level from here", pass options. Both are measured from the root and are unaffected by which page is asking:
| Call | Returns |
|---|---|
cms_categories() |
One level, relative to the current page |
cms_categories({depth: 1}) |
The root categories, on every page |
cms_categories({depth: 2}) |
Roots + their children, as one flat list |
cms_categories({parentSlug: 'x'}) |
Everything below x, as one flat list |
The depth/parentSlug results are flat, parents before children, so
build the nesting yourself (a recursive macro is the usual shape) using
c.parent.
Category URLs are flat at any depth
/categories/{slug} carries no parent path, so a subcategory's URL is
/categories/driewielers, not /categories/scootmobielen/driewielers.
The slug resolves anywhere in the tree, so you can link any category the
same way regardless of how deeply it is nested.
Product images: never print img.path raw
Product, variant and category images are stored as a bare media path
(foo.webp), not a URL. Put that straight into an <img src> and the
browser resolves it against the current page (/nl/products/foo.webp)
and gets a 404. cms_product_image() adds the /media/ route prefix:
<img src="{{ cms_product_image(p.images|first) }}" alt="">
<img src="{{ cms_product_image(p.images|first, {w: 600, fit: 'cover'}) }}" alt="">
It accepts an image-array entry ({path, alt}), a bare path string
(what variant and category imagePath hold), or null, and returns ''
for anything empty, so it is safe to print unconditionally. Passing a
value that already carries the prefix is fine, it is not doubled.
Prefer cms_product_img(), which emits the whole tag with width and
height:
{{ cms_product_img(p.images|first, {w: 600, class: 'rounded', loading: 'eager'}) }}
w, h, fit, q and fmt become transform params and also drive the
emitted dimensions; every other attribute is rendered verbatim. alt
falls back to the image array's own alt, then '', and is always
present. loading defaults to lazy, so pass eager (and
fetchpriority: 'high') for the LCP image.
Pass sizes and the tag also gets a srcset, so the browser downloads
the size it needs instead of the one src:
{# Product card: 3 columns from 768px, full width below. #}
{{ cms_product_img(p.images|first, {w: 640, sizes: '(min-width: 768px) 33vw, 100vw'}) }}
{# Square crop: every candidate keeps the 1:1 ratio. #}
{{ cms_product_img(p.images|first, {w: 400, h: 400, fit: 'cover', sizes: '(min-width: 1024px) 25vw, 50vw'}) }}
{# A fixed-size thumbnail: name the widths yourself (1x and 2x). #}
{{ cms_product_img(img, {w: 64, h: 64, fit: 'cover', widths: [64, 128], sizes: '64px'}) }}
The candidates come from the ladder 320/480/640/960/1280/1920 (or your
widths), each a ?w= transform carrying your other params, and stop
at the widest the original can deliver, since /media never upscales.
w is still the src for anything that ignores srcset. An image with
no stored size (an SVG) gets no srcset, and neither does a GIF: an
animated GIF is served as its original whatever the params, so every
candidate would be the same file. An animated WebP or AVIF is served as
its original too, but both keep their srcset: most are stills, and which
ones move is not known when the page renders. A srcset you pass
yourself wins over the generated one.
Without sizes the tag is a single src, as before. Without w that
src is the full original: a 3000px upload in a 300px card. Always pass
at least w.
This matters beyond tidiness: unlike field('...', 'image'), catalog and
article images get no server-side enrichment, so without this helper
every product <img> ships without dimensions and the page reflows as
images arrive (bad CLS, and Google notices).
cms_effective_price(product, qty = 1, variant = null)
The price after automatic catalog rules, when the
catalog_discount module is on.
Reachable from a sandboxed theme since cms-core 0.4.34; before that it was registered but missing from the catalog module's allowlist, so a theme calling it hit a sandbox error.
{% set ep = cms_effective_price(p) %}
{% if ep.on_sale %}
<span class="was">{{ ep.base_gross|cms_money(ep.currency) }}</span>
<span class="now">{{ ep.price_gross|cms_money(ep.currency) }}</span>
<span class="badge">-{{ (ep.saving_bp / 100)|round }}%</span>
{% else %}
{{ ep.price_gross|cms_money(ep.currency) }}
{% endif %}
Returns {on_sale, base_net, base_gross, price_net, price_gross, saving_net, saving_bp, currency}. Every figure comes in both net and
gross (VAT from the product's tax class) so themes never repeat the math.
Pass qty to preview a volume break, which is how you render a "10 or
more: €4.50 each" line on a product page:
{% set ten = cms_effective_price(p, 10) %}
Pass a variant to price that specific unit. Omit it for the product's own
price; "from €X" across variants is cms_price()'s job.
With the module off, on_sale is false and base == price, so the
else branch is the whole behaviour and a shared theme needs no gate.
Allowed entity getters (in addition to core's Page/Website/Media):
Product: id, sku, slug, name, shortDescription, description, priceCents, priceGrossCents, minOrderQuantity, currency, stock, trackStock (viaisTrackStock), weightGrams, images, hasVariants, variants, optionTypes, isPublished, isFeatured, seo, taxClass, categories, tags, createdAt, updatedAt.ProductVariant: id, sku, priceCents, priceGrossCents, stock, weightGrams, isDefault, isAvailable, optionValues, name, …
cms_price()applies catalog discount rules since cms-core 0.4.34. It resolves the price through the samePriceResolverthe cart and checkout use, so the shelf and the till agree. Before 0.4.34 it printed the stored price while the cart charged the reduced one.It returns one figure, so it cannot show a "was" price on its own. For the crossed-out price and the saving, use
cms_effective_price. On a variant productcms_price()is the "from €X" figure, resolved at quantity 1: a listing price is what one unit costs, so a VOLUME tier is never quoted there.
Two of those need a word of warning:
priceGrossCentsis the price to display.priceCentsis always net. A shop can enter prices incl. VAT, in which case the typed shelf price is stored andpriceGrossCentsreturns it verbatim; computing gross yourself aspriceCents * (10000 + taxClass.rateBp) / 10000can land a cent below it (€10.00 at 21% stores 826 net, which derives back to €9.99).cms_pricealready does the right thing, so prefer it.minOrderQuantityis the smallest quantity the cart accepts (1 = no minimum). Use it for theminand initialvalueof your quantity input: the cart rejects anything lower with acart_errorflash, so a form that starts at 1 on a 15-minimum product is a dead end for the shopper.ProductOptionType: id, code, label, values.ProductOptionValue: id, code, label, type.Category: id, slug, name, description, imagePath, navOrder, isPublished, seo, parent, children, createdAt, updatedAt.Tag: id, slug, name, description, isPublished.TaxClass: id, code, label, rateBp.
Reference: operators/modules/catalog.md.
Customer module (customer)
Auth + account flows. The form helpers below render opaque themeable
forms — the templates are at @GsidCmsCore/customer/_*_form.html.twig
and themes override at @website/customer/<file>.html.twig. Don't try
to compose these with the generic form_* helpers — the underlying
controllers expect specific field names.
| Helper | What it renders / returns |
|---|---|
is_customer_authenticated() |
true if a Customer is logged in on this request |
cms_customer() |
The current Customer entity, or null |
cms_login_form(opts = {}) |
The login form |
cms_register_form(opts = {}) |
The registration form |
cms_password_forgot_form(opts) |
"Forgot password" request form |
cms_password_reset_form(opts) |
"Set a new password" form (token-bound) |
cms_account_profile_form(opts) |
Profile edit form (name, company, VAT number, language, marketing flag) |
cms_account_password_form(opts) |
Change-password form |
cms_account_addresses_view(opts) |
Address book listing |
cms_account_address_form(opts) |
Single-address edit form |
cms_account_nav(opts = {}) |
The account-area sidebar navigation |
Allowed entity getters:
Customer: id, email, isVerified (viaisVerified), firstName, lastName, locale, marketingOptIn, lastLoginAt, createdAt, addresses, plus Symfony'sgetUserIdentifier/getRoles.CustomerAddress: kind (shipping/billing), isDefault, company, vat, firstName, lastName, street, postalCode, city, region, country, phone, createdAt, updatedAt.
Reference: operators/modules/customer.md.
Cart module (cart)
Depends on catalog + customer.
| Helper | What it renders / returns |
|---|---|
cms_cart() |
The current Cart entity, or null when empty |
cms_cart_count() |
Total quantity (int) |
cms_cart_total() |
Pre-formatted gross total |
cms_cart_view(opts = {}) |
Full cart-page rendering |
cms_mini_cart(opts = {}) |
Compact cart widget for the header |
cms_cart_notice(opts = {}) |
"Item added" success/error flash banner |
cms_cart_flashes() |
Raw flash list, in case the theme wants its own renderer |
Allowed entity getters:
Cart: id, currency, items, totalQuantity, subtotalCents, customer, sessionId, createdAt, updatedAt.CartItem: id, product, taxClass, quantity, priceCentsAtAdd, nameAtAdd, lineSubtotalCents, lineTaxCents, unitPriceGrossCents, lineGrossCents, createdAt, updatedAt.
Reference: operators/modules/cart.md.
Discount module (discount)
Depends on cart.
The discount module adds no Twig functions of its own and nothing
to the sandbox allowlist. When it's enabled and the shopper has applied
a valid code, the cms_cart_view() summary gains three keys you render
inside the cart/checkout template:
| Summary key | Type | Meaning |
|---|---|---|
discount |
int (cents) | Amount taken off the current cart (0 if no code). |
discountType |
string | null | percent / fixed / free_shipping / bogo, or null. |
freeShipping |
bool | true when a free-shipping code is waiving shipping. |
The apply/remove form isn't shipped in a core template — render your own
code input and POST it to /cart/discount (apply) or
/cart/discount/remove (clear); the outcome comes back through the same
cms_cart_flashes() / cms_cart_notice() cart-flash channel.
Reference: operators/modules/discount.md.
Gift card module (giftcard)
Depends on cart.
| Helper | Returns |
|---|---|
cms_gift_card_summary() |
{enabled, cards, total, amountDue, currency} for the current cart |
Each card is {id, code, amount, balanceAfter}, amounts in cents and
code already masked (•••• M2PQ). Test enabled, not cards, to hide
the gift card block: an empty list also means "none entered yet", which
is exactly when the code box must show. The cms_cart_view() summary
carries the same as giftCard, giftCards and amountDue.
Apply a code by posting code to /cart/gift-card; remove one by posting
its id as gift_card_id to /cart/gift-card/remove. Both answer through
the cart flashes, like the discount endpoints.
Allowed entity getters:
GiftCard: id, formattedCode, initialCents, balanceCents, currency, isActive, expiresAt, recipientName, message, sendOn, sentAt, createdAt. Notcode, and no getter on an order leads to a card.Order: giftCardCents, amountDueCents.OrderItem: isGiftCardLine, recipientName, recipientEmail, giftMessage, giftSendOn.Product: isGiftCard, to show the recipient fields only on a gift card product; see the module page.
Reference: operators/modules/giftcard.md.
Checkout module (checkout)
Depends on cart + catalog + customer.
The checkout module ships five Twig functions; everything else is
entity getters, with the order and items handed in by the controller's
render context. (cms_orders / cms_order, an order-history listing,
are still unbuilt.)
| Helper | Returns |
|---|---|
cms_tracking_url(order) |
Carrier track-and-trace URL for an order, or null |
cms_vacation_notice() |
The shop-closure banner as HTML, or '' when there is none |
cms_vacation() |
VacationPeriod for a theme building its own, or null |
cms_delivery_slot_picker() |
The delivery moment picker for the checkout form, as HTML |
cms_delivery_moment(order, locale = null) |
The order's delivery moment as one sentence, or '' |
cms_tracking_url(order)
The carrier's track-and-trace URL for an order, or null.
{% if order.trackingCode %}
{% set url = cms_tracking_url(order) %}
{% if url %}
<a href="{{ url }}" rel="noopener">{{ order.trackingCode }}</a>
{% else %}
<span>{{ order.trackingCode }}</span>
{% endif %}
{% endif %}
null means one of three things: no tracking code is set yet, the order
was a pickup, or the carrier is not one of the six with a known lookup
URL (PostNL, DHL, DPD, GLS, UPS, FedEx).
Print the bare code in that case, as above. Hiding the shipment
because you cannot link it leaves the customer with nothing; a code they
can paste into the carrier's own site is still useful. Do not
{% if url %} around the whole block.
cms_delivery_slot_picker()
The day and time window picker of the
delivery moments module
(delivery_slot), for the checkout form. The bundled checkout page calls
it after the shipping options. A theme that overrides
checkout/index.html.twig must call it too, inside the form, where the
picker goes:
{{ cms_delivery_slot_picker() }}
Without it the shopper cannot pick, and once the shop has set its windows every delivery order fails with "Pick a delivery moment."
It takes no arguments: it renders from the checkout page's own
delivery_slot and errors variables, prints the field error itself,
and always renders the wrapper
<div data-cms-checkout-live="delivery-slot">, empty when the order is
not asked for a moment. Call it unconditionally: the empty wrapper is
where the picker appears when the shopper switches from a pickup to a
delivery, and checkout_live.js redraws it when the day changes. The
markup comes from checkout/_delivery_slot.html.twig, which a theme can
override at @website/checkout/_delivery_slot.html.twig (same inputs).
delivery_slot on the checkout page is null when the order is not asked
(module off, no windows set, a pickup option chosen), otherwise:
{
days: [
{ date: '2026-10-22', label: 'donderdag 22 oktober 2026',
windows: [ { value: '09:00-12:00', start: '09:00', end: '12:00' }, … ] },
…
],
selected_date: '2026-10-22', # the posted day while it is on offer, else the first
selected_window: '', # never pre-selected: '' until the shopper picks
windows: [ … ] # the windows of selected_date
}
Post the day as delivery_date and the window's value as
delivery_window. An empty days is a shop that asks but has nothing in
range: say so, because the order cannot be placed. The server looks the
posted pair up in the schedule again on submit, so the values rendered
here are advisory.
cms_delivery_moment(order, locale = null)
The delivery moment the shopper picked, as one sentence in the render's
language, or '' when the order has none (a pickup, a shop without the
module, an older order):
{% if order.hasDeliverySlot %}
<p>{{ cms_delivery_moment(order) }}</p>
{% endif %}
{# "Delivery on Thursday, October 22, 2026 between 13:00 and 17:00" #}
{# "Bezorging op donderdag 22 oktober 2026 tussen 13:00 en 17:00" #}
The bundled thank-you page, order mails, account order page and packing
slip print it; a theme that overrides one of those adds it itself. In a
mail pass the order's locale, as with cms_date():
cms_delivery_moment(order, locale).
cms_vacation_notice() and cms_vacation()
A shop can close for a holiday without closing the shop: checkout stays
open and the storefront promises a later dispatch date. Operators set the
dates in Module settings → Checkout → Holiday closure.
{{ cms_vacation_notice() }}
That is the whole integration for most themes. It renders the banner — the shop's own sentence when it wrote one, otherwise a generated one in the render's language — and an empty string when there is nothing to say: checkout off, no closure configured, one already over, or one still further out than the shop's announcement lead time. So call it unconditionally; on a non-shop site it renders nothing.
Put it where it will actually be read: the header, above the add-to-cart button, on the delivery-information page. The cart and the checkout render it themselves, so a shop gets it in the two places that matter whether or not its theme was ever updated.
To build your own markup instead:
{% set away = cms_vacation() %}
{% if away %}
<p class="closed{{ away.isActive ? ' closed--now' : '' }}">
Wij zijn gesloten t/m {{ cms_date(away.until) }} —
verzending vanaf <strong>{{ cms_date(away.resumesOn) }}</strong>.
</p>
{% endif %}
VacationPeriod exposes exactly four getters: from, until,
resumesOn (the day after until, which is the date every notice
quotes) and isActive. Branch on isActive for tone: an upcoming
closure is information, one that is running while the customer is about
to pay is the reason their parcel will sit for a week.
For a smaller change than rebuilding the markup, override the banner
template at @website/checkout/_vacation.html.twig — it receives
vacation and message (the resolved sentence). The default carries
structural class names only (cms-vacation, cms-vacation--active /
--upcoming) and sets role from the tense.
A theme overriding checkout/index.html.twig also gets vacation in the
render context, so it can place the warning inside its own layout instead
of calling the function.
cms_date(date, locale = null, weekday = false)
Not checkout-specific — it is available on every site — but it arrived
with the closure banner and is what you want next to one. Twig's own
|date formats by pattern, not by locale, so a template wanting a
readable date had to pick one language and use it everywhere:
{{ away.resumesOn|date('d F Y') }} {# "28 August 2026", on a Dutch page #}
{{ cms_date(away.resumesOn) }} {# "28 augustus 2026" #}
It reads the same bundled string packs as cms_t(), so a date and the
sentence around it cannot end up in different languages. Pass locale
explicitly where the render's locale is the wrong one — an email built
from a queue speaks the language the order was placed in, not the
language of whatever triggered the job.
weekday: true puts the day's name in front, for a day someone has to be
home or turn up on:
{{ cms_date(order.deliveryDate, null, true) }} {# "donderdag 22 oktober 2026" #}
{{ cms_date(order.deliveryDate, 'en', true) }} {# "Thursday, October 22, 2026" #}
Allowed entity getters:
Order: status, customer, email, shippingAddress, billingAddress, currency, subtotalCents, taxCents, shippingCents, totalCents, paymentMethod, paymentMethodDetail and paymentMethodLabel (the method picked inside the gateway,ideal/ "iDEAL",nullwhen the provider's own page asked), paymentReference, items, createdAt, updatedAt, dispatchFrom (the promised ship date on an order taken during a closure,nullotherwise), hasDeliverySlot, deliveryDate, deliveryWindowStart and deliveryWindowEnd (the delivery moment the shopper picked: the day, and the window as"13:00"/"17:00"; allnullandhasDeliverySlotfalse on an order without one), discountCents, discountCode, giftCardCents, amountDueCents, and for orders taxed by the VAT engine: pricesIncludeVat, goodsGrossCents (goods incl. VAT, before the voucher) and vatBreakdown (the VAT per rate the total includes), …VacationPeriod: from, until, resumesOn, isActive.OrderItem: product, taxClass, name + sku snapshot, quantity, unitPriceCents, unitPriceGrossCents, lineSubtotalCents, lineTaxCents, lineGrossCents, taxRateBp (the rate frozen at placement), and on VAT engine orders discountAllocationCents and lineGrossBeforeDiscountCents (the line incl. VAT before the voucher, as a receipt lists it).PaymentGatewayInterface: id, label, requiredFields.
The checkout's gateways is a list of plain arrays, one per payment
choice: id (what the radio posts), label, and for a method listed
from Mollie also gateway (mollie), method (ideal), image (the
logo as an SVG URL, image2x a 2x PNG) and min_cents / max_cents.
A theme that reads only g.id and g.label lists every method as a
plain radio; {% if g.image %}<img src="{{ g.image }}" alt="">{% endif %}
adds the logo.
Symfony\Component\Uid\Uuid:__toString,toRfc4122,toBase58,toBase32— used because Order ids are Uuids.
Reference: operators/modules/checkout.md.
Portfolio module (portfolio)
Named groups of photographs, each backed by a media folder. No dependencies.
| Helper | Returns |
|---|---|
cms_portfolio(options = {}) |
The whole switcher, pills plus panels, as HTML |
cms_portfolio_collections() |
Every publishable collection, in pill order (list of maps) |
cms_portfolio_collection(slug) |
One collection by slug, or null |
All three return '' / [] / null when the module is off or nothing
is publishable, so a shared theme calls them unconditionally.
{{ cms_portfolio({ heading: 'Werk', columns: 3, radius: '16px' }) }}
Options: heading, columns (default 4), radius (any CSS length),
lightbox (default true), aria_label. The bundled switcher is a real
tablist with arrow-key navigation and #slug deep links, and it degrades
to all-panels-visible without JavaScript. Override it wholesale at
@website/portfolio/_switcher.html.twig.
Set radius here rather than in your stylesheet: portfolio.css loads
after the theme's sheet, so a --cms-portfolio-radius you declare at
equal specificity loses.
To build your own markup:
{% for c in cms_portfolio_collections() %}
<section id="{{ c.slug }}">
<h2>{{ c.name }} <small>{{ c.count }}</small></h2>
{% for p in c.photos %}
<img src="{{ p.src }}" alt="{{ p.alt }}"
width="{{ p.width }}" height="{{ p.height }}"
loading="{{ p.loading }}">
{% endfor %}
</section>
{% endfor %}
Collections are plain maps, not entities: {slug, name, count, photos}, where each photo is {src, alt, caption, width, height, loading}. src is already a /media/... URL, and loading is eager
for the first four of a group and lazy after, so passing it through
gets the LCP behaviour right for free.
A collection with no photographs never appears in either list, so a pill always has something behind it. That also means a group you expect and cannot find is an empty (or image-less) media folder.
Reference: operators/modules/portfolio.md.
FAQ module (faq)
Questions and answers, optionally grouped into categories. Each category and question belongs to one language: these helpers answer for the locale being rendered and never fall back to another one. No dependencies.
| Helper | Returns |
|---|---|
cms_faq(options = {}) |
The whole accordion as HTML, structured data included |
cms_faq_categories() |
Published categories that have questions, in editor order (list of maps) |
cms_faq_questions() |
Published questions in no category, in editor order (list of maps) |
cms_faq_category(slug) |
One category by slug, or null |
cms_faq_search(query, limit = 20) |
{items, total, query}, ranked matches |
cms_faq_jsonld(category = null) |
The schema.org FAQPage map, or null |
All of them return '' / [] / null when the module is off or nothing
is published, so a shared theme calls them unconditionally.
{{ cms_faq() }}
{{ cms_faq({ category: 'verzending', heading: 'Verzending en retour' }) }}
| Option | Default | What it does |
|---|---|---|
category |
none (every category) | Render only this category slug |
search |
false |
Search box above the questions, a plain GET ?q= |
heading |
none | An <h2> above the questions |
open_first |
false |
Expand the first question |
aria_label |
Frequently asked questions |
Accessible name for the region |
jsonld |
true |
Emit the FAQPage block |
Call it twice on a page and pass jsonld: false on the second call.
One page should carry one FAQPage; two is a structured-data error, and
this is the only footgun in the helper.
The accordion is <details>/<summary>, so every question opens and
closes without JavaScript; faq.js only opens the question a
#faq-<slug> link points at. Override the markup wholesale at
@website/faq/_faq.html.twig, and the page wrapper for /faq at
@website/pages/faq.html.twig. Most restyling is covered by the
--cms-faq-* custom properties in cms-public/public/assets/faq.css.
To build your own markup:
{% for q in cms_faq_questions() %} {# the ones in no category #}
<h3 id="{{ q.anchor }}">{{ q.question }}</h3>
{{ q.answer|raw }}
{% endfor %}
{% for c in cms_faq_categories() %}
<h2><a href="{{ c.url }}">{{ c.name }}</a></h2>
{% if c.intro %}<p>{{ c.intro }}</p>{% endif %}
{% for q in c.items %}
<h3 id="{{ q.anchor }}">{{ q.question }}</h3>
{{ q.answer|raw }}
{% endfor %}
{% endfor %}
Categories are plain maps, not entities: {slug, name, intro, url, count, items, updated_at}. A question is {slug, question, answer, anchor, url, updated_at}, and search results carry an extra category
key which is null when the question is in none.
answer is HTML that was sanitised when it was saved, so render it with
|raw, because escaping it shows the visitor the tags. anchor is
faq-<slug>, prefixed so an embedded FAQ cannot collide with an id the
theme already uses; put it on the heading and deep links keep working.
url can be an empty string: the FAQ routes are not registered in the
page-editor render inside cms-api. Test it before making a link, the way
the bundled template does, or the editor shows dead links where the site
shows real ones.
For a search box, use cms_faq({ search: true }). It reads ?q=
server-side and renders the results; a theme cannot read the query string
itself, because app.request is not in the sandbox.
cms_faq_search(term) is for a term the template already has (a row of
suggested questions, say) and returns {items, total, query} with each
item carrying its category map.
Reference: operators/modules/faq.md.
Vehicle stock module (vehicle_stock)
A car dealer's stock. The module ships its own pages, so a theme needs
nothing to have them: /occasions (the overview, with filters) and
/occasions/{slug} (one car), under the language prefix on a multi-language
site. These helpers are for putting cars anywhere else (a "latest stock"
block on the homepage) and for themes that build their own pages.
| Helper | Returns |
|---|---|
cms_vehicles(opts = {}) |
One page of cars (a list of car arrays, see below) |
cms_vehicle_search(opts = {}) |
{ items, total, page, pages }, for a list with paging |
cms_vehicle(slug) |
One car array, or null (drafts are never found) |
cms_vehicle_facets(make = null, sold = true) |
What the stock can be filtered on, with counts (see below) |
cms_vehicles_url(filters = {}) |
The overview's URL with those filters, in the page's language |
cms_vehicle_jsonld(car) |
schema.org Car for a car array, or null with the SEO module off |
opts takes the same keys as the overview's query string, so a link and a
template call mean the same thing: make, model, fuel, body,
transmission, price_max (whole euros), year_min, km_max, sort
(newest, price_asc, price_desc, km_asc, year_desc), limit
(default 24, at most 100), page, and sold. A value it does not know is
ignored, not an error.
{# The newest six cars for sale, on the homepage. #}
{% for car in cms_vehicles({ limit: 6, sold: false }) %}
<a href="{{ car.url }}">
{{ cms_product_img(car.image, { w: 480, h: 320, fit: 'cover', alt: car.title }) }}
{{ car.title }}, {{ car.year }}, {{ car.mileage }}, {{ car.price }}
</a>
{% endfor %}
<a href="{{ cms_vehicles_url({ make: 'Volvo' }) }}">All Volvos</a>
Sold cars stay
A sold car stays on the website, marked sold, until an editor deletes it:
its page keeps working for whoever shared or bookmarked it, and lists show
it after the cars for sale. Pass sold: false to leave sold cars out of a
list. Drafts never show anywhere.
A car is an array
Not the entity: the array is the contract, so nothing outside it (the VIN, the platform listings) is reachable from a template. Every value comes raw (for a CSS class or a filter link) and labelled in the page's language:
| Key | Example |
|---|---|
url, slug, title, make, model, variant |
/occasions/toyota-corolla, Toyota Corolla 1.8 Hybrid |
status / status_label, available, reserved, sold |
sold / Verkocht, false, false, true |
condition / condition_label |
used / Gebruikt |
price, price_cents, price_on_request, vat_regime / vat_label |
€ 19.995, 1999500, false, margin / Marge |
year, first_registration, apk_until |
2020, 2020-03-15, 2027-03-15 (dates as YYYY-MM-DD; print with cms_date(date(car.apk_until))) |
mileage, mileage_km |
84.250 km, 84250 |
fuel / fuel_label, secondary_fuel, hybrid |
petrol / Hybride (benzine), electric, true |
transmission / transmission_label, body_type / body_type_label |
automatic / Automaat, estate / Stationwagen |
colour / colour_label |
grey / the maker's colour name when there is one, else Grijs |
power, power_kw, power_hp, engine_cc, co2_g_km |
90 kW (122 pk), 90, 122, 1798, 102 |
doors, seats, owners, kenteken, dealer_ref |
5, 5, 1, AB-123-C, V-1042 |
description |
plain text in the page's language; print with |nl2br |
equipment |
[{ group, label, items: ['Navigatiesysteem', ...] }], catalogue groups in order |
equipment_extra |
the dealer's own lines: ['Trekhaak afneembaar'] |
images, image |
[{ path, url, alt }] and the first one; give an entry to cms_product_img() |
A value the dealer left empty is null.
Facets
cms_vehicle_facets() returns makes, models (only when a make is
passed), fuels, bodies and transmissions, each a list of
{ value, count } (plus label on the last three), and the ranges
price: { min, max } (whole euros), year: { min, max } and km: { max }.
The bundled overview builds its filter form from them.
Overriding the pages
Mirror the path to override: pages/vehicles.html.twig (the overview),
pages/vehicle.html.twig (one car) and vehicle_stock/_card.html.twig
(one car in a list, used by the bundled overview). The context each one
gets is listed at the top of the bundled file.
A CMS page with the slug occasions takes the overview's URL over, so a
dealer's own stock page (with their own copy around a cms_vehicles()
block) wins; the car pages stay the module's.
Enquiry form. The bundled car page includes
vehicle_stock/_enquiry.html.twig from the theme when it exists, with
car in its context, and leaves it out on a sold car. Put the theme's
contact form there with the car in a hidden field, so the submission says
which car it was about:
{{ form_start('car-enquiry') }}
{{ form_input('vehicle', 'hidden', { value: car.title ~ ' ' ~ car.kenteken|default('') }) }}
{{ form_label('name', 'Naam') }} {{ form_input('name') }}
{{ form_label('email', 'E-mail') }} {{ form_input('email', 'email', { required: true }) }}
{{ form_submit('Vraag naar deze auto') }}
{{ form_end() }}
Reference: operators/modules/vehicle-stock.md.
Cookie consent
Not a module: the cookie wall is a per-website switch every tenant has. Both helpers are always in the sandbox.
cms_consent_granted(kind)
Whether the visitor allowed one kind of cookie: 'statistics' or
'marketing'. False until the visitor has chosen, and for any other
name.
{% if cms_consent_granted('marketing') %}
{# an ads or remarketing script #}
{% endif %}
Google and Meta tags need no gate: the Google and Meta Pixel modules load them from the shared base layout, after consent.
cms_consent()
The raw choice: 'accepted' (both kinds), 'statistics',
'marketing', 'rejected', or null when the visitor has not chosen
yet (or chose under an older policy version). Prefer
cms_consent_granted().
cms_consent() == 'accepted' is true only when both kinds were allowed.
Treat null as 'rejected': a != 'rejected' test would load the
script for everyone who has not answered, which is exactly the case the
banner exists for.
cms_cookie_banner()
Renders the banner. Drop it once in the base layout, near the end of
<body>:
{{ cms_cookie_banner() }}
It returns '' when the wall is off for this website or the visitor has
already chosen on the current policy version, so it needs no gate. Its
copy follows the request locale in eleven languages, English outside
them (the list is in
cookie-consent.md).
To restyle it, override @website/cookie/banner.html.twig, loading the
shared assets/cookie-banner.js like the shipped file does and keeping its hooks (data-cms-cookie-consent
on the form, data-action accept / reject / save, data-cms-category on
the two switches, data-cms-toggle on the settings button), or the
choice never posts. The full list is in
cookie-consent.md.
Reference: operators/cookie-consent.md.
Standard Symfony helpers (allowlisted)
asset asset_version cycle date max min path random range url
asset(path) resolves to the cms-public host's public dir (e.g.
asset('assets/blocks.css')). For theme assets use theme_asset() —
those live on NFS, not in cms-public's public dir.
Registered but not available to themes
Two functions exist in cms-core's Twig extensions and are not in the sandbox allowlist, so calling them from a theme raises a sandbox error. They are listed here only so you stop looking for them:
page_parent_choices(page): candidate parent pages for the admin's page-settings drawer. Admin-side only, by design; a theme has no use for a list of every page including drafts.cms_effective_price(product, qty, variant): see above. This one looks like an oversight rather than a decision, and needs a cms-core release to fix.
Everything reachable is in ThemeSandboxPolicy::FUNCTIONS plus the
per-module SandboxContributorInterface implementations, which are the
source of truth if this page and the code ever disagree.
Entity accessors
Page and Website entities are in scope. Allowed read-only accessors
(see ThemeSandboxPolicy::METHODS for the source of truth):
Page
: getId getSlug getTitle getLocale getTemplate getMetaDescription getFields getSeo isPublished getPublishedAt getUpdatedAt getParent getNavOrder getWebsite getMenus appearsIn
getParent is a leftover from before nesting moved to the Menus tab.
The admin no longer sets it, so a page created since answers null. For
hierarchy, read nav_tree() or cms_breadcrumb_items().
On a multi-locale site the page in scope is a LocalizedPage — the
same page seen through one locale, with that locale's translation laid
over it. It answers every accessor above identically, so templates need
no change, and adds one:
: isTranslated — false when this locale has no published translation
and the page is falling back to the default language. See
multi-locale.md.
Website
: getId getCode getName getTheme getThemeVersion getDefaultLocale getLocales isActive getHosts
WebsiteHost
: getId getHostname isCanonical
Media (when handling raw media references)
: getId getPath getFilename getMimeType getSize getWidth getHeight getAlt getCaption getUploadedAt getUpdatedAt isImage
Property access ({{ obj.foo }}) only works for arrays and stdClass.
For Doctrine entities use the listed getters.