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()fortitleandmetaDescriptionso editors can edit them in place. Importing_shared/_macros.html.twigascmsand writing{{ cms.meta('title', page.title) }}is the idiomatic form. -
Use
page_path(p)/page_url(p)for links to other pages — pass thePageentity, never compose URLs by hand. For static-slug shortcuts (homepage, contact),path('page', {slug: '…'})is fine too. Hardcoded/aboutworks 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, andcms_company_href('phone')builds thetel: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 byThemeAssetsController. -
Use
asset()only for files in cms-public'spublic/assets/(the sharedblocks.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.twigdoesn't fit the website, shiptemplates/_partials/seo.html.twig(without the_shared/prefix — the@websitenamespace points at your theme'stemplates/). 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_beaconis 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.localecontrols<html lang>. Avoid hardcoded strings in templates; usefield()so they're editable. -
Make images responsive. The
MediaControllerhonours?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 addingfmt=. 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 withobject-fitif the layout needs a crop. Usesrcsetfor 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,onsubmitand 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 anaddEventListenerin the theme'sapp.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
pageandwebsiteentities; 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.twigneeds 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. Usecms_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
embedfor a video from the media library. That type is for third-party players (YouTube, Vimeo, a map). A video you uploaded goes in avideofield, or throughcms_video()when the theme owns the source. -
Don't fork
_shared/_partials/seo.html.twigunless 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:clearto 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.themeVersionnot 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
Repositorymethod 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 skipfield()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-namewrapper for the SPA to find. Use thetextfield type instead — it renders content with no partial wrapper, andfield()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 testingcms_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 %} -
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|jointhe 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.