Do and don't

Prescriptive rules. Read this last.

Do

  • Use field('name', 'type') for every editable content block. Field names are yours to invent (hero_title, service_intro, …); types must be one of those listed in field-types.md. Consistent names across pages let editors reuse shapes.

  • Use cms_meta() for title and metaDescription so editors can edit them in place. Importing _shared/_macros.html.twig as cms and writing {{ cms.meta('title', page.title) }} is the idiomatic form.

  • Use page_path(p) / page_url(p) for links to other pages — pass the Page entity, never compose URLs by hand. For static-slug shortcuts (homepage, contact), path('page', {slug: '…'}) is fine too. Hardcoded /about works only until someone renames the page.

  • Use cms_company() for site-wide contact details — phone, e-mail, address, KvK, social profiles. Editors maintain them under Websites → Contact, and cms_company_href('phone') builds the tel: form from whatever they typed. See twig-helpers.md.

  • Use theme_asset() for files shipped in the theme tarball (compiled CSS, theme images, optional theme JS). Resolves to NFS paths served by ThemeAssetsController.

  • Use asset() only for files in cms-public's public/assets/ (the shared blocks.css, carousel.css, etc.). Theme assets are NOT in cms-public's public dir — asset() won't find them.

  • Mirror shared file paths when overriding. If _shared/_partials/seo.html.twig doesn't fit the website, ship templates/_partials/seo.html.twig (without the _shared/ prefix — the @website namespace points at your theme's templates/). Same name, theme override wins.

  • Stick to the block contract. Override only the blocks listed in _shared/base.html.twig (seo, stylesheets, head_extra, header, nav, body, footer, javascripts, analytics_beacon). Don't introduce new blocks — your theme will silently fall out of sync with cms-core changes. (analytics_beacon is overridable specifically to disable or replace the built-in analytics beacon.)

  • Test in every locale. A multilingual website has one Page row per locale, and page.locale controls <html lang>. Avoid hardcoded strings in templates; use field() so they're editable.

  • Make images responsive. The MediaController honours ?w=...&h=...&fit=... query params and serves AVIF or WebP when the browser advertises support. AVIF is made in the background the first time a size is asked for, so that first request (and any before the worker gets to it) gets WebP; leave the format to the browser rather than adding fmt=. An animated GIF, WebP or AVIF is the exception: it is served as the original, at its own size and ratio, whatever the params, so give it a box with object-fit if the layout needs a crop. Use srcset for hero images:

    <img src="{{ src }}?w=1920&h=1080&fit=cover"
         srcset="{{ src }}?w=960&h=540&fit=cover 960w,
                 {{ src }}?w=1920&h=1080&fit=cover 1920w,
                 {{ src }}?w=2880&h=1620&fit=cover 2880w"
         sizes="100vw">
    
  • Set loading="eager" only on the LCP element, lazy on everything else. Field renderers default to lazy; explicit eager is for hand-rolled hero markup.

  • Respect prefers-reduced-motion. If a theme adds animation beyond what the modules already do, gate it on the media query.

  • Render uploaded video through cms_video(). It emits the poster, the intrinsic dimensions (so the page doesn't reflow), and the rendition set the player picks a size from. A hand-rolled <video> gets none of that and serves the full-size file to every phone.

Don't

  • Don't write PHP. Themes run inside the Twig sandbox. There is no way to call a service or repository, even if you find one in scope.

  • Don't write an inline on*= handler. onclick, onchange, onsubmit and the rest. A nonce can go on a <script> tag and never on an attribute, so one handler anywhere keeps the whole platform's script policy loose. Reach for 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. A <script> tag you write in a template gets its nonce automatically; you never have to think about it.

  • Don't add Twig tags, filters, functions, or method accessors that aren't already in ThemeSandboxPolicy. Adding one requires editing cms-core. Themes can't.

  • Don't add new field types in templates. field('foo', 'pricing-table') throws because the type isn't allowlisted. New types go in cms-core (see field-types.md).

  • Don't fetch from the database. The renderer hands you the page and website entities; that's the entire input. If you find yourself wanting "all products" or "all blog posts", that's a content shape the CMS doesn't model yet — talk to backend before working around it.

  • Don't put site data in a theme macro. A phone number written into _macros.html.twig needs a deploy to change, and the page stops opening in the inline editor: that renders one {% block %}, which never runs the template's top-level {% import %}, so the alias is null and the macro call is a hard 500 — while the same template still renders fine on the public site. Use cms_company() for data, and keep macros for parameterised markup components.

  • Don't bypass the form helpers. form_start/form_input/etc. emit signed schemas that the server validates. Hand-rolled <form> tags get rejected on submit.

  • Don't try to autoplay a video with sound. Browsers refuse it, so the video simply never starts. cms_video(src, {autoplay: true}) forces muted for exactly this reason — don't fight it by hand-rolling the element.

  • Don't use embed for a video from the media library. That type is for third-party players (YouTube, Vimeo, a map). A video you uploaded goes in a video field, or through cms_video() when the theme owns the source.

  • Don't fork _shared/_partials/seo.html.twig unless you accept owning the entire SEO surface for the website. The default emits title, description, canonical, Open Graph, JSON-LD, hreflang. Most themes shouldn't override {% block seo %} at all.

  • Don't add JavaScript unless the design requires it. If you do, ship a plain ES module in the theme tarball and load it via theme_asset('app.js'). No bundler, no npm, no imports of third-party libraries. If you need a library, talk to backend about adding it as a shared module.

  • Don't commit styles.css (the compiled Tailwind output) to the theme repo. The deploy worker produces it. Source-only repos.

  • Don't cache:clear to fix what looks like a stale theme. A theme that doesn't show changes after a deploy is more likely a tag/version mismatch (Website.themeVersion not bumped) than a Symfony cache issue. Check the theme deployment status row first.

  • Don't put database calls or expensive logic in templates. If a page render walks 1000 items, that's 1000× per request. Heavy lifting belongs in a Repository method or a server-side aggregate that lands on the page entity by the time Twig runs.

  • Don't break the editor. A field with a data-cms-field-name="..." marker must contain renderable HTML — empty markers, mis-nested tags, or <script>-injected content confuse the iframe editor. The default field partials get this right; if you write custom markup around a field, keep it simple.

  • Don't reach past field() to render content yourself. It's tempting, when the theme wants to own the outer tag (a hero <h1> with bespoke Tailwind classes, a <span> caption, a <dt> label, …), to skip field() and write something like:

    {# Don't do this — the editor can't bind to it. #}
    {% set hero = page.getFields()['hero_title']|default({}) %}
    <h1 class="font-display ..."> {{ hero.content|default(page.title)|raw }} </h1>
    

    That works on the public site but makes the field invisible to the inline editor: there's no data-cms-field-name wrapper for the SPA to find. Use the text field type instead — it renders content with no partial wrapper, and field() still emits the editor markers in editor mode:

    <h1 class="font-display ..."> {{ field('hero_title', 'text') }} </h1>
    

    Same outer tag, same classes, fully editable. See field-types.md#text.

  • Don't gate field wrappers behind {% if x is defined %} without also testing cms_is_editing(). Themes often skip an image/section wrapper on the public site when nothing's stored, e.g.:

    {# Editor sees nothing — no way to fill the field. #}
    {% if banner.src is defined %}
        <div class="banner-wrapper">{{ field('banner', 'image') }}</div>
    {% endif %}
    

    In editor mode that wrapper needs to render so the editor can hover and pick an image. Widen the gate:

    {% if banner.src is defined or cms_is_editing() %}
        <div class="banner-wrapper">{{ field('banner', 'image') }}</div>
    {% endif %}
    

    See twig-helpers.md#cms_is_editing.

  • Do treat a collection accessor as a read-only list. Several entity accessors hand you a Doctrine collection rather than an array: product.categories, product.variants, product.tags, variant.optionValues, category.children, cart.items, order.items, customer.addresses. Filters and tests work on them the same as on an array:

    {% set primary = product.categories|first|default(null) %}
    {% if cart.items is not empty %}{{ cart.items|length }}{% endif %}
    

    Two limits. Mutating one is forbidden (items.add(...), .remove(...) and friends raise) — a template must not edit the entity graph. And printing a collection directly is meaningless: {{ product.categories }} emits an internal object id, not a list. Loop it, or |join the values you want.

When in doubt

Re-read getting-started.md. Most "why doesn't this work" questions in themes trace back to a sandbox denial that's easier to spot once you know the boundary.