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>).

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_error flash, 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.

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.

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 (via isTrackStock), 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 same PriceResolver the 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 product cms_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:

  • priceGrossCents is the price to display. priceCents is always net. A shop can enter prices incl. VAT, in which case the typed shelf price is stored and priceGrossCents returns it verbatim; computing gross yourself as priceCents * (10000 + taxClass.rateBp) / 10000 can land a cent below it (€10.00 at 21% stores 826 net, which derives back to €9.99). cms_price already does the right thing, so prefer it.
  • minOrderQuantity is the smallest quantity the cart accepts (1 = no minimum). Use it for the min and initial value of your quantity input: the cart rejects anything lower with a cart_error flash, 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 (via isVerified), firstName, lastName, locale, marketingOptIn, lastLoginAt, createdAt, addresses, plus Symfony's getUserIdentifier/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. Not code, 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", null when the provider's own page asked), paymentReference, items, createdAt, updatedAt, dispatchFrom (the promised ship date on an order taken during a closure, null otherwise), hasDeliverySlot, deliveryDate, deliveryWindowStart and deliveryWindowEnd (the delivery moment the shopper picked: the day, and the window as "13:00" / "17:00"; all null and hasDeliverySlot false 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.

Not a module: the cookie wall is a per-website switch every tenant has. Both helpers are always in the sandbox.

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.

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.

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.