Craftcms-claude-skills craft-twig-guidelines

Twig coding standards and conventions for Craft CMS 5 templates. ALWAYS load this skill when writing, editing, or reviewing any .twig file in a Craft CMS project — even for small edits. Covers: variable naming (camelCase, no abbreviations), null handling (?? operator, ??? with empty-coalesce plugin), whitespace control ({%- trimming, NOT {%- minify -%}), include isolation (always use 'only'), Craft Twig helpers ({% tag %}, tag(), attr(), |attr filter, |parseAttr, |append, svg()), collect() for props and class collections, .implode(), comment headers with ========= separators on component files, and common pitfalls (snake_case, macros as components, hardcoded colors). Triggers on: Twig template creation, editing, or review; .twig files; {% include %} with 'only'; {% tag %} and polymorphic elements; collect() and props.get(); class string building; attr() and |attr filter; svg() with styling and aria; ?? and ??? null coalescing; whitespace control and blank lines in output; minify alternatives; Twig file headers and comment blocks; variable naming conventions in Twig; currentSite, siteUrl, craft.entries, .eagerly(), .collect in template context. NOT for Twig architecture patterns, atomic design structure, or template routing (use craft-site). NOT for PHP code (use craft-php-guidelines). NOT for content modeling or field configuration (use craft-content-modeling).

install
source · Clone the upstream repo
git clone https://github.com/michtio/craftcms-claude-skills
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/michtio/craftcms-claude-skills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/craft-twig-guidelines" ~/.claude/skills/michtio-craftcms-claude-skills-craft-twig-guidelines && rm -rf "$T"
manifest: skills/craft-twig-guidelines/SKILL.md
source content

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

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-empty-coalesce
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 %}

Twig 3.21.x (Craft 5) does not have the nullsafe operator (

?.
). That requires Twig 3.23+. Use
??
and ternaries instead:

{# Can't do this yet #}
{{ entry?.author?.fullName }}

{# Do this instead #}
{{ entry.author.fullName ?? '' }}

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
    . Never
    el
    ,
    hd
    .
  • false
    omits an attribute entirely from the rendered HTML.
  • null
    also omits. Use
    false
    when explicitly excluding,
    null
    when absent.
  • class
    accepts arrays with automatic falsy filtering.
  • aria
    and
    data
    accept nested hashes that expand to
    aria-*
    /
    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' } }) }}
  • 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.

collect()
Conventions

collect()
wraps a Twig hash into a Collection object. Primary use cases:

Props collection

{%- set props = collect({
    heading: heading ?? null,
    content: content ?? null,
    utilities: utilities ?? null,
}) -%}

{# Access with get() #}
{{ props.get('heading') }}
{{ props.get('size', 'text-base') }}

{# Merge additional props #}
{%- set props = props.merge({ icon: icon ?? null }) -%}

Class collection (named keys)

{%- set classes = collect({
    layout: 'flex items-center gap-2',
    color: 'bg-brand-primary text-white',
    hover: 'hover:bg-brand-accent',
    utilities: props.get('utilities'),
}) -%}

class="{{ classes.implode(' ') }}"

Null values in

collect()
produce harmless extra spaces when joined — browsers normalize whitespace in class attributes. Use
classes.filter(v => v).implode(' ')
if you want pristine output for devMode inspection, but plain
implode(' ')
is fine for production.

Entry queries as Collections

{# .collect instead of .all() when you need Collection methods #}
{%- set entries = craft.entries.section('blog').eagerly().collect -%}
{%- set featured = entries.filter(e => e.featured).first -%}

Common Pitfalls

  1. ???
    operator without the plugin
    — requires
    nystudio107/craft-empty-coalesce
    or
    nystudio107/craft-seomatic
    . Check
    composer.json
    before using. Default to
    ??
    .
  2. snake_case variables — use camelCase:
    heroImage
    not
    hero_image
    .
  3. Missing
    only
    — silent variable leaking, invisible coupling.
  4. {%- minify -%}
    — deprecated. Use
    {%-
    whitespace control.
  5. Abbreviations
    el
    ,
    btn
    ,
    nav
    ,
    desc
    ,
    ctr
    → spell it out.
  6. is not defined
    — verbose null checking.
    ??
    handles it.
  7. Macros as components — wrong scoping, no extends/block support.
  8. Hardcoded colors in class strings
    bg-yellow-600
    bg-brand-accent
    .
  9. String concatenation for classes
    'flex ' ~ extraClass
    → use
    collect({})
    with named keys.
  10. options.x
    pattern
    — old macro convention. Use direct variable names.