Twig Coding Standards — Craft CMS 5
Coding conventions for Twig templates in Craft CMS 5 projects. These apply to all Twig code — atomic components, views, layouts, builders, partials.
Companion Skills — Always Load Together
When this skill triggers, also load:
craft-site— Template architecture and component patterns. Required when creating or editing components, layouts, views, or builders.craft-content-modeling— Content architecture. Required when template code involves element queries, field access, or section decisions.
For Twig architecture patterns (atomic design, routing, builders), see the
craft-site skill. For PHP coding standards, see craft-php-guidelines.
Documentation
- Twig in Craft: https://craftcms.com/docs/5.x/development/twig.html
- Template tags: https://craftcms.com/docs/5.x/reference/twig/tags.html
- Template functions: https://craftcms.com/docs/5.x/reference/twig/functions.html
- Twig 3 docs: https://twig.symfony.com/doc/3.x/
Use WebFetch on specific doc pages when something isn't covered here.
Variable Naming
Single-word, descriptive, lowercase preferred. When multi-word is needed, use camelCase.
{# Correct #}
{% set heading = entry.title %}
{% set image = entry.heroImage.one() %}
{% set items = navigation.links.all() %}
{% set element = props.get('url') ? 'a' : 'span' %}
{% set buttonText = entry.callToAction %}
{% set containerClass = 'max-w-3xl' %}
{# Wrong — abbreviations #}
{% set el = props.get('url') ? 'a' : 'span' %}
{% set btn = entry.callToAction %}
{% set nav = navigation.links.all() %}
{# Wrong — snake_case #}
{% set button_text = entry.callToAction %}
{% set container_class = 'max-w-3xl' %}
No abbreviations: element not el, button not btn, navigation not nav,
description not desc.
Prefer single-word names when context makes the meaning clear (e.g. heading
inside a component is better than sectionHeading). But multi-word camelCase is
perfectly fine when needed for clarity.
Null Handling
?? is the default. Always safe, always portable.
??? (empty coalesce) is acceptable if the project already has nystudio107/craft-emptycoalesce or nystudio107/craft-seomatic installed — both provide the operator. But never install a plugin just for ???. Check composer.json first.
{# Always correct #}
{% set heading = entry.heading ?? '' %}
{% set image = entry.heroImage.one() ?? null %}
{{ props.get('label') ?? 'Default' }}
{# OK if empty-coalesce or SEOmatic is installed — checks empty, not just null #}
{% set heading = entry.heading ??? '' %}
{# Wrong — verbose, unnecessary #}
{% if entry.heading is defined and entry.heading is not null %}
{% if entry.heading is not defined %}
Craft 5 supports the nullsafe operator (?.). Use it for deep traversal through
chains that may have null links — it propagates null cleanly without the verbose
is defined and is not null dance:
{# Reach for ?. when any link in the chain may be null #}
{{ entry?.author?.fullName ?? 'Anonymous' }}
{# ?? alone is enough when only the leaf is in question #}
{{ entry.title ?? '' }}
?? stays the right tool for simple "value or fallback" cases; ?. is for chains
where intermediate links may be missing. Don't reach for ?. on a single property
access — it adds noise without adding safety.
Whitespace Control
Use {%- and {{- for whitespace trimming. Never use {%- minify -%}.
{# Correct — surgical whitespace control #}
{%- set heading = entry.title -%}
{%- if heading -%}
{{- heading -}}
{%- endif -%}
{# Wrong — deprecated minification approach #}
{%- minify -%}
{% set heading = entry.title %}
{%- endminify -%}
Apply whitespace control on tags that produce unwanted blank lines in output. Not every tag needs it — use where visible output whitespace matters.
Include Isolation
Every {% include %} MUST use only. No exceptions.
{# Correct — explicit, isolated #}
{%- include '_atoms/buttons/button--primary' with {
text: entry.title,
url: entry.url,
} only -%}
{# Wrong — ambient variables leak in #}
{%- include '_atoms/buttons/button--primary' with {
text: entry.title,
url: entry.url,
} -%}
Without only, a component can silently depend on variables from its parent
scope, creating invisible coupling.
No Macros for Components
Never use {% macro %} for UI components. Macros don't support extends/block
and their scoping model differs from includes.
{# Wrong — macro for a component #}
{% macro button(text, url) %}
<a href="{{ url }}">{{ text }}</a>
{% endmacro %}
{# Correct — include with isolation #}
{%- include '_atoms/buttons/button--primary' with {
text: text,
url: url,
} only -%}
Macros are acceptable for utility functions that return strings (e.g., formatting helpers), not for rendering UI.
Comment Headers
Every component file gets a section header comment:
{# =========================================================================
Component Name
Brief description of what this component does.
========================================================================= #}
Props files, variant files, views, layouts — all get headers. The =========
separator matches the PHP convention from craft-php-guidelines.
Craft Twig Helpers
{% tag %} — Polymorphic Elements
Primary tool for rendering elements whose tag name depends on props.
{%- set element = props.get('url') ? 'a' : 'span' -%}
{%- tag element with {
class: classes.implode(' '),
href: props.get('url') ?? false,
target: props.get('target') ?? false,
rel: props.get('rel') ?? false,
aria: {
label: props.get('label') ?? false,
},
} -%}
{{ props.get('text') }}
{%- endtag -%}
Rules:
- Variable name must be descriptive:
element,heading,wrapper. Neverel,hd. falseomits an attribute entirely from the rendered HTML.nullalso omits. Usefalsewhen explicitly excluding,nullwhen absent.classaccepts arrays with automatic falsy filtering.ariaanddataaccept nested hashes that expand toaria-*/data-*attributes.
tag() — Inline Element Function
For simple elements without complex inner content:
{{ tag('span', { class: 'sr-only', text: '(opens in new window)' }) }}
{{ tag('img', { src: image.url, alt: image.title, loading: 'lazy' }) }}
{{ tag('i', { class: ['fa-solid', icon], aria: { hidden: 'true' } }) }}
{# Craft 5.10+: pass a string as the second arg as a text-only shortcut #}
{{ tag('span', 'Read more') }}
text:key = HTML-encoded content.html:key = raw HTML content (trusted input only).- Self-closing elements (
img,input,br) handled automatically.
attr() — Attribute Strings
For building attributes in non-tag contexts:
<div{{ attr({ class: ['card', active ? 'card--active'], data: { id: entry.id } }) }}>
Returns a space-prefixed attribute string. Same false-means-omit and class
array filtering as {% tag %}.
|attr Filter
For merging attributes onto existing HTML strings:
{{ svg('@webroot/icons/check.svg')|attr({ class: 'w-4 h-4', aria: { hidden: 'true' } }) }}
|parseAttr Filter
For extracting attributes from an HTML string into a hash for manipulation:
{% set attributes = '<div class="foo" data-id="1">'|parseAttr %}
{# attributes = { class: 'foo', data: { id: '1' } } #}
|append Filter
For adding content to an element string:
{{ svg('@webroot/icons/logo.svg')|append('<title>Company Logo</title>', 'replace') }}
svg() Function
{{ svg('@webroot/icons/logo.svg') }}
{{ svg(entry.svgField.one()) }}
Combine with |attr for classes and aria attributes. Use |append for
accessible labels inside the SVG.
heading() / h() / h1()…h6() — Programmatic Headings (Craft 5.10+)
Build heading tags from a dy