Twig Templates and Debugging in Drupal

This content was generated with the assistance of AI and should be verified against the official Drupal documentation before being relied on in production.

This sub-section’s Drupal bibliography lists the reference material consulted while preparing these pages.

Every piece of HTML that Drupal sends to a browser passes through a Twig template, apart from the markup that a few APIs write directly. Twig is the template language that replaced PHPTemplate in Drupal 8: a small, sandboxed language with variables, a few control structures, filters and functions, and automatic output escaping. Drupal adds its own filters, functions and one tag on top of Twig’s built-ins. A themer uses Twig to restructure the markup of nodes, fields, blocks, menus and pages; a site builder meets it when a template shows the wrong thing; a developer meets it when a module provides a theme hook or a Single Directory Component.

This page was checked against the official documentation on 2026-10-30. It is written against Drupal 11.4.x, Drush 13 or 14 and PHP 8.3 or later (see Requirements and hosting for the full baseline). The examples belong to the fictitious Meridian Museum site from the sub-section index, whose theme meridian lives in web/themes/custom/meridian and was generated with the Starterkit (see Starterkit and subthemes). The Drupal 10 line ends on 2026-12-09, so new work should target Drupal 11. Drupal 12.0.0 is planned for the week of 2026-12-07; the notes below say where a feature depends on the 11.x minor.

The page is the template-language reference for the theming pages. How a render array becomes a call to a template is explained in Theming overview and the render pipeline. Choosing which template file wins and moving data into a template with preprocess functions is the subject of Template suggestions and preprocess. Libraries are covered in Libraries and assets, components in Single Directory Components, and custom filters and functions that modules can add in Twig extensions in modules.

How Twig fits in Drupal

Drupal does not render a Twig template from your file directly. The process has four stages:

  1. A render array or a theme hook call ('#theme' => 'node') asks the theme system for the hook node.

  2. The theme registry resolves the hook to a template file and a list of suggestions, by scanning the enabled modules and the active theme and its base themes. The registry is cached, which is why a newly added template file is not noticed until the caches are rebuilt.

  3. Preprocess functions (and OOP hooks, see Hooks and OOP hooks) fill the variables array.

  4. Twig loads the template, compiles it to a PHP class stored in the Twig cache (by default under the public files directory, in a php/twig folder), and executes it with the variables as its context.

The practical consequence is a rule of thumb: a change to the markup of an existing, already compiled template needs a Twig cache clear (or Twig debug mode, which enables automatic reload), while a new template file or a new suggestion needs a full cache rebuild so that the registry rediscovers it.

flowchart TD A["Render array with #theme or #type"] --> B["Theme registry lookup (cached)"] B --> C["Suggestions: most specific file name first"] C --> D["Preprocess functions and OOP hooks fill variables"] D --> E["Twig loads template and compiles it to a PHP class"] E --> F["Compiled class runs with variables as context"] F --> G["Autoescaped HTML string, with render-array children rendered recursively"] G --> H["Placeholders replaced, assets attached, cache metadata bubbled"]

The version of Twig is 3.x in every supported Drupal core. The official landing page links to the Twig 3.x documentation for the language itself, and the pages in this section describe only what is specific to Drupal.

Twig syntax as Drupal uses it

Twig has three delimiters:

  • {{ ... }} prints the result of an expression.

  • {% ... %} executes a statement (if, for, set, trans, include, embed, apply).

  • {# ... #} is a comment that never reaches the output.

A comment in a template is the right place for documentation, because HTML comments are sent to the visitor. Drupal’s own templates start with a {# block that documents the available variables, which is the quickest reference when you copy a template into a theme.

Variables and dot notation

Twig reads a variable with the dot operator. node.label tries an array key, then a property, then a method, in that order, so the same notation reads variables['node'], an object property or the label() method. On a content entity the notation gets long but regular:

  • node.label calls label().

  • node.id calls id(), node.bundle calls bundle().

  • node.field_accession_number.value reads the main property of the first item of a field.

  • node.field_gallery.0.target_id reads a property of a specific item.

  • node.field_hero.entity.label follows an entity reference and calls a method on the referenced entity.

  • node.created.value is a raw timestamp. It is a plain integer, so it needs format_date.

{#
/**
 * @file
 * Theme override for an exhibition node.
 *
 * Available variables: node, label, content, url, attributes, view_mode, teaser, page
 */
#}
{% set classes = [
  'node',
  'node--type-' ~ node.bundle|clean_class,
  node.isPromoted() ? 'node--promoted',
  view_mode ? 'node--view-mode-' ~ view_mode|clean_class,
] %}
<article{{ attributes.addClass(classes) }}>
  {{ title_prefix }}
  {% if not page %}
    <h2{{ title_attributes.addClass('node__title') }}>
      <a href="{{ url }}" rel="bookmark">{{ label }}</a>
    </h2>
  {% endif %}
  {{ title_suffix }}

  <div{{ content_attributes.addClass('node__content') }}>
    {{ content|without('field_related_objects') }}
  </div>

  {% if content.field_related_objects|render|trim is not empty %}
    <aside class="node__related" aria-labelledby="related-heading">
      <h2 id="related-heading">{{ 'Objects in this exhibition'|t }}</h2>
      {{ content.field_related_objects }}
    </aside>
  {% endif %}
</article>

Several habits in this template are worth spelling out.

  • {{ attributes.addClass(classes) }} prints a leading space in front of every attribute. That is why the template writes <article{{ attributes }}> without a space before the brace.

  • {{ title_prefix }} and {{ title_suffix }} are render arrays that other modules fill (for example contextual links). Leaving them out silently removes those features from the page.

  • The without filter prints content minus a child, so that the child can be placed elsewhere.

  • The ternary node.isPromoted() ? 'node--promoted' without an else branch yields an empty value, which addClass ignores.

  • Strings are concatenated with ~, not with +.

Calling a method is allowed in Twig only through the sandbox policy that Drupal applies; see The Twig sandbox and which methods a template may call.

Control structures

Twig’s if, for, set, include, embed, extends/block and apply all work in Drupal templates. A few points need care.

  • for introduces the loop variable (loop.index, loop.first, loop.last). Menu templates use it to mark the first and last item.

  • include and embed resolve a template name through Drupal’s Twig loader. Every enabled module and theme is a namespace named after its machine name, so {% include '@meridian/partials/opening-hours.html.twig' %} loads partials/opening-hours.html.twig from the theme meridian. A relative path from the templates folder is also accepted.

  • A component of the Single Directory Components system is included by its plugin id, {% include 'meridian:card' with { title: label } only %}; the only keyword stops the parent’s whole context leaking into the component. See Single Directory Components.

  • {% set %} creates a variable for the rest of the template (not for an include`d template that uses `only).

  • {% apply %} applies a filter to a block of output, for example {% apply spaceless %}; see The Twig 3 and 11.x changes for why you should not need it.

{# web/themes/custom/meridian/templates/block/block--meridian-collection-on-display.html.twig #}
{% set classes = ['block', 'block-on-display'] %}
<section{{ attributes.addClass(classes) }}>
  {{ title_prefix }}
  {% if label %}
    <h2{{ title_attributes.addClass('block__title') }}>{{ label }}</h2>
  {% endif %}
  {{ title_suffix }}
  <ul class="block-on-display__list">
    {% for item in content.items %}
      <li class="{{ loop.first ? 'is-first' }}">{{ item }}</li>
    {% else %}
      <li>{{ 'Nothing is on display today.'|t }}</li>
    {% endfor %}
  </ul>
</section>

The {% else %} branch of for runs when the sequence is empty, which saves an extra if. The template name above follows the block pattern block--[module]--[delta] described below, assuming the block plugin "On display now" of meridian_collection has the plugin id meridian_collection_on_display; underscores in the machine name become hyphens in the file name.

Translation: t, trans and placeholder

User-interface strings in a template must be translatable. Drupal offers a filter and a tag for it, and both pass the string through the same translation system that PHP code uses (see Translation in code).

<a href="{{ path('<front>') }}">{{ 'Back to the museum'|t }}</a>

<p>{{ 'Submitted by @author on @date'|t({'@author': author_name, '@date': date}) }}</p>

<p>
  {% trans %}
    There is {{ count }} object on display in {{ gallery }}.
  {% plural count %}
    There are {{ count }} objects on display in {{ gallery }}.
  {% endtrans %}
</p>

The rules that matter:

  • t and trans are synonyms for the filter. They are meant for literal interface text. The official page marks {{ var1|t }} and {% trans %}{{ var1 }}{% endtrans %} as unsafe, because a variable is not a string that the translation extractor can find and, if it contains user input, it becomes a translation source string.

  • Placeholders follow the t() rules: @name escapes the value, %name escapes and emphasises it, and :name is for URLs and sanitises the protocol.

  • The placeholder filter escapes a value and wraps it in <em>; it does not translate anything. It is used inside a trans block, for example {% trans %}Submitted on {{ date|placeholder }}{% endtrans %}.

  • The {% plural count %} form selects the singular or plural message and is the only correct way to pluralise. Concatenating an "s" does not work in languages with other plural rules, and the Meridian site has Spanish content.

Drupal filters

Drupal registers its filters in Drupal\Core\Template\TwigExtension. The ones a themer uses most are these.

  • t / trans: translate a literal string, described above.

  • placeholder: escape a value and emphasise it, inside trans.

  • clean_class: converts any string to a valid CSS class name (it uses Html::getClass()); it lower-cases the string and turns spaces and underscores into hyphens. Use it for every class built from data, for example 'node--type-' ~ node.bundle|clean_class.

  • clean_id: converts a string to a valid HTML id (Html::getId()). It does not guarantee uniqueness across a page.

  • clean_unique_id (10.1 and later): creates an id that is unique for every use of the template on a page, even when the template is rendered several times. Prefer it to clean_id for ids in repeated components such as cards and accordions.

  • without: prints a render array, or an Attribute object, minus the listed keys. It works on a copy, so the original is still intact for a later {{ content.field_x }}. The call {{ content|without('links', 'field_x') }} accepts several keys.

  • render: renders a render array to a string immediately. It is useful for testing whether a field produced any output, and it also works on strings and on objects with __toString().

  • safe_join: joins a list of strings with a separator and escapes each item, unless the item is already marked safe. {{ tags|safe_join(', ') }}.

  • format_date: formats a Unix timestamp using a date format configured at Configuration > Regional settings > Date and time formats: {{ node.created.value|format_date('short') }}, or {{ node.created.value|format_date('custom', 'j F Y') }}.

  • add_suggestion (10.0 and later): adds a theme suggestion with the highest priority to a render array. {{ content.body|add_suggestion('details') }} makes Drupal look for field--details.html.twig first.

  • add_class and set_attribute (10.1 and later): add a CSS class or set an HTML attribute on the render array of a field without writing a preprocess function. {{ content.field_hero|add_class('hero__media') }}, {{ content.field_image|set_attribute('fetchpriority', 'high') }}.

  • raw: Twig’s built-in filter that marks a string as safe and disables escaping. See the next section.

The official filter page does not document the filters passthrough or drupal_escape that older tutorials sometimes list; they are used by Drupal’s translation machinery and are not meant to be called from a theme.

A worked example: the exhibition teaser

{# web/themes/custom/meridian/templates/content/node--exhibition--teaser.html.twig #}
{{ attach_library('meridian/exhibition-teaser') }}
{% set classes = [
  'exhibition-teaser',
  'exhibition-teaser--' ~ node.field_status.value|clean_class,
] %}
{% set date_range %}
  {{ node.field_dates.value|date('j M Y') }} &ndash; {{ node.field_dates.end_value|date('j M Y') }}
{% endset %}
<article{{ attributes.addClass(classes).setAttribute('data-nid', node.id) }}>
  <a href="{{ url }}" class="exhibition-teaser__link" rel="bookmark">
    {{ content.field_hero|add_class('exhibition-teaser__image') }}
    <h3 class="exhibition-teaser__title">{{ label }}</h3>
  </a>
  <p class="exhibition-teaser__dates">{{ date_range|trim }}</p>
  {{ content|without('field_hero', 'field_dates', 'field_status', 'links') }}
</article>

Details of this template: the date filter on a date range field formats the stored ISO string, while a field that stores a Unix timestamp (such as created) needs format_date; the {% set date_range %}...{% endset %} form captures output into a variable; and attributes are chained (addClass(...).setAttribute(...)) because both methods return the Attribute object.

Using render to test for empty fields

A field that is empty still exists in content as a render array in some situations (for example with a formatter that prints a label), so {% if content.field_x %} is not a reliable test. Two patterns work:

{# 1. Render and look at the string. #}
{% if content.field_notes|render|trim is not empty %}
  <div class="notes">{{ content.field_notes }}</div>
{% endif %}

{# 2. Better for fields: look at the items without rendering them. #}
{% if node.field_notes is not empty %}
  <div class="notes">{{ content.field_notes }}</div>
{% endif %}

The first pattern has a classic trap: the renderer marks a render array as printed once it has been rendered, and a second print of the same array is empty. Test with |render only on a copy or in a place where the field is not printed again, or use the second pattern, or move the check to a preprocess function. |render still bubbles the cache metadata of the field to the page, so the test does not break caching.

Drupal functions

Drupal core adds the following functions. A theme cannot define its own Twig functions or filters: they must come from a module (see Twig extensions in modules).

  • attach_library('meridian/exhibition-teaser'): attaches an asset library defined in the theme’s or a module’s *.libraries.yml. Use it in the template that needs the asset, so that the CSS and JavaScript load only on pages where the component appears. Library definitions and dependencies are in Libraries and assets.

  • link(text, uri, attributes): builds a link from text and a URL, a route Url object or a string. It also outputs a span or button for menu items using the "nolink" or "button" options, and it adds the is-active class when a Url object has set_active_class set. The attributes argument should be an Attribute object; a plain array still works but is discouraged.

  • path(route_name, parameters, options): generates a relative URL from a route name. Options can add a query: {{ path('entity.node.canonical', {'node': node.id}, {'query': {'ref': 'newsletter'}}) }}.

  • url(route_name, parameters, options): the same, but absolute. The special route names <front>, <current> and <none> are accepted by both path and url.

  • file_url(uri): converts a file URI such as public://collection/lamp.jpg into a root-relative URL, using the file URL generator service. It is for a file entity’s URI value, for example {{ file_url(node.field_brochure.entity.fileuri) }}. An image style needs its own URL, which a preprocess function or the image formatter builds; see Display modes and image styles.

  • create_attribute(map): builds an Attribute object in the template; see the next section.

  • active_theme() and active_theme_path(): print the machine name or the relative path of the active theme. Use the path for assets that the library system cannot handle, such as an inline SVG sprite.

  • render_var(render_array): the function form of the render filter.

  • Built-ins that matter: include, embed, dump (only in debug mode), date, max, min and range.

<nav class="meridian-footer-nav" aria-label="{{ 'Footer'|t }}">
  <ul>
    <li>{{ link('Visit', path('entity.node.canonical', {'node': 12}), create_attribute({'class': ['footer-nav__link']})) }}</li>
    <li>{{ link('Collection'|t, url('view.collection.page_1'), create_attribute({'class': ['footer-nav__link']})) }}</li>
    <li><a href="{{ path('<front>') }}">{{ 'Home'|t }}</a></li>
    <li><a href="{{ url('<current>', {}, {'query': {'lang': 'es'}}) }}">Es</a></li>
  </ul>
  <img src="{{ file_url('public://meridian-logo.svg') }}" alt="{{ site_name }}" width="120" height="32">
</nav>

A few cautions: path() and url() need a route that exists, otherwise rendering throws an exception and the page shows the error page (a hard-coded node id such as 12 is poor practice; use a menu or a configuration value in real sites); file_url() on a private file produces the /system/files URL and the visitor still needs permission to download it (see File system and private files); and a hard-coded link text such as 'Visit' above is not translatable unless it passes through the t filter.

Twig Tweak, Devel and other contrib helpers

Many tutorials use functions that are not in core: drupal_view(), drupal_block(), drupal_entity() and drupal_field() belong to the contrib module Twig Tweak, and devel_dump(), kint() and devel_breakpoint() to Devel. They are convenient but they put query and rendering logic into the templates, which makes the theme depend on a module. The official guidance favours preparing data in preprocess functions or in a block or a component, so that a template stays a view of data. Core does not ship these functions, so a template that uses them breaks when the module is not installed.

Autoescaping and the risks of raw

Autoescaping is on for every template. Anything printed with {{ }} is passed through an HTML escaping step, unless it is marked as safe. A string is marked safe when it is an instance of Drupal\Component\Render\MarkupInterface (for example the result of Markup::create(), a TranslatableMarkup, a FormattableMarkup, an Attribute or the rendered output of a render array). Render arrays that you print ({{ content.body }}) are rendered by the renderer and the resulting markup is safe, because the field formatter or text format has already filtered it. That is why printing a render array is safe, and printing a plain string that came from a user is safe too.

flowchart TD A["Value printed with a double-brace expression"] --> B{"Is it a render array?"} B -- yes --> C["Renderer renders it: formatter, text format and filters have already sanitised the markup"] B -- no --> D{"Is it MarkupInterface or marked safe?"} D -- yes --> E["Printed as is"] D -- no --> F["HTML-escaped: lt, gt, amp, quotes"] G["raw filter on user data"] --> H["Escaping skipped: stored XSS if the value contains markup"]

The raw filter turns escaping off for a value. It is dangerous because Twig cannot tell trusted text from user input:

{# Dangerous: a curator or a visitor who can enter this text can inject a script. #}
<p class="lead">{{ node.field_summary.value|raw }}</p>

{# Safe: escaped by default. #}
<p class="lead">{{ node.field_summary.value }}</p>

{# Safe: processed through the field's text format, with the format's filters applied. #}
<div class="lead">{{ content.field_summary }}</div>

The security rules to remember:

  • Never apply raw to a value from a field, a form, a query string, a Webform submission or an external API. The same holds for Markup::create() and for '#markup' in PHP; see Security hardening.

  • Use raw only for literal text that you wrote in the template or for output that a trusted function already sanitised; and then prefer a variable marked safe in PHP over a filter in the template.

  • The |striptags filter is not a sanitiser and must not be used to make user HTML safe. Text formats and Xss::filter() are the supported tools.

  • Autoescaping protects HTML text and attribute values, not URLs. A user-controlled value in href is not stripped of a javascript: protocol by escaping. Print URLs through the link field formatter, link() or a Url object, which handle protocols; for the :name placeholder in t, the protocol is sanitised.

  • Do not build attributes by concatenating strings (<div class="{{ user_input }}"> is escaped, but a user-controlled attribute name is not possible to escape). Use the Attribute object, which escapes names and values.

  • Contrib JavaScript that reads data- attributes is a second place where untrusted data is used. Treat it as text, not markup.

Where text comes from a Meridian curator through CKEditor 5, the text format (for example basic_html) decides which tags survive. Configuration of formats and the roles that may use them is in Text formats and CKEditor 5.

The Attribute object

HTML attributes in templates are held in Drupal\Core\Template\Attribute objects. Preprocess creates them and passes them to the template as attributes, title_attributes, content_attributes and so on. The class collects, sanitises and renders HTML attributes, and it is the main reason you rarely write class="..." strings with variables in them.

Public methods you can call from Twig (the Attribute class is allowed by the sandbox policy, so all its methods work):

  • addClass(...): adds one or several classes, or an array, merging them into the existing list.

  • removeClass(...): removes classes.

  • hasClass(name): tests for a class.

  • setAttribute(name, value): sets an attribute, replacing any previous value.

  • removeAttribute(name, ...): deletes attributes.

  • hasAttribute(name): tests for an attribute.

  • merge(other): merges another Attribute object into this one, joining class lists and replacing other values.

  • toArray(), storage() and getClass(): read the stored values.

Printing the object ({{ attributes }}) renders every attribute as name="value" with a leading space, and escapes names and values. The without filter drops keys on a copy: <div{{ attributes|without('id') }}>.

{# Build an attribute set in the template. #}
{% set link_attributes = create_attribute({
  'class': ['button', 'button--primary'],
  'data-analytics': 'ticket-click',
}) %}

<a href="{{ ticket_url }}"{{ link_attributes.setAttribute('rel', 'noopener').addClass('is-large') }}>{{ 'Buy tickets'|t }}</a>

{# Pass the attributes of the parent to a child, minus the classes. #}
<div{{ attributes|without('class') }}>
  <span{{ create_attribute().addClass('badge') }}>{{ badge }}</span>
</div>

{# Test before you print. #}
{% if attributes.hasClass('is-active') %}
  <span class="visually-hidden">{{ 'Current page'|t }}</span>
{% endif %}

Source: Twig in Drupal (the topic pages "Using attributes in templates" and "Modifying attributes in a .theme file" cover the object in detail)

Common attribute pitfalls:

  • The object is mutable and shared. attributes.addClass('x') changes the object itself, so a second print later in the same template shows the class too. Use create_attribute() for a separate set, or without for a filtered copy.

  • Forgetting the leading space. <div {{ attributes }}> produces two spaces; harmless, but <div{{ attributes }}> is the convention used throughout core.

  • Printing a class list directly. Writing class="{{ attributes.class }}" prints the class parts separately. Print the Attribute object, or use addClass.

  • Missing attributes on a template override. When you override a core template and delete {{ attributes }}, contextual links, data-drupal-selector and JavaScript behaviours attached through them stop working.

  • Setting id twice. Two components with the same id break anchors and ARIA relationships. clean_unique_id avoids this.

  • Attribute values are escaped, not validated. setAttribute('href', user_value) is escaped, but not stripped of javascript: protocols (see the previous section).

In PHP, preprocess functions use the same object: $variables['attributes']['class'][] = 'x' for a plain array, and $variables['attributes']->addClass('x') for an object. Which form you get depends on the hook; test with instanceof Attribute.

The Twig sandbox and which methods a template may call

Drupal applies a Twig sandbox policy (Drupal\Core\Template\TwigSandboxPolicy) so that a template author cannot call arbitrary methods on an object. In a template you may call:

  • methods whose names start with get, has or is (the default allowed prefixes);

  • the methods id, label, bundle, get, __toString and toString (the default allowed methods);

  • any method of Drupal\Core\Template\Attribute (the default allowed class).

A call that is not allowed throws a security exception when the template renders. The policy is configurable in settings.php through twig_sandbox_allowed_classes, twig_sandbox_allowed_methods and twig_sandbox_allowed_prefixes (the older "whitelisted" names were removed in 10.0). From 11.2 the default for the allowed methods was narrowed to specific classes, entries without a class name are deprecated, and custom entries should use the Class::method form. The recommendation of the change record is not to widen the list but to add a preprocess function or a Twig filter that performs the call safely.

<?php

// web/sites/default/settings.php -- only if a method really cannot be moved to preprocess.
$settings['twig_sandbox_allowed_methods'] = [
  'id',
  'label',
  'bundle',
  'get',
  '__toString',
  'toString',
  'Drupal\meridian_collection\Entity\CollectionObject::isOnDisplay',
];

The example repeats the defaults and adds one method in the Class::method form that 11.2 and later expect. Widening the sandbox exposes more of the object graph to anybody who can edit a template, which on a site with a theme developer team is a review point.

Template naming and location

Drupal chooses a template by name. Each theme hook has a base template in core or in a module (node.html.twig for node), and its suggestions are more specific names. The file name is the hook or suggestion with every _ turned into -, and a double underscore in a suggestion turned into a double hyphen (block__system_menu_block becomes block--system-menu-block.html.twig).

  • Node, most specific first: node--[nodeid]--[viewmode], node--[nodeid], node--[content-type]--[viewmode], node--[content-type], node--[viewmode], node.

  • Field: field--node--[field-name]--[content-type], field--node--[field-name], field--node--[content-type], field--[field-name], field--[field-type], field. Custom field names carry the field- prefix: field--field-accession-number.html.twig.

  • Block: block--[module]--[delta], block--[module], block. A Views block is block--views-block--[view]-[display]; Layout Builder inline blocks are block--inline-block--[block-type].

  • Page: page--front for the front page, and one suggestion per path element, with numeric elements dropped from the later ones. For /node/1/edit the list is page--node--edit, page--node--1, page--node and page. The suggestions use the internal path, not the alias.

  • Views: views-view--[view]--[display], views-view--[view], views-view, and similar chains for rows, fields and tables.

  • Taxonomy term: taxonomy-term--[tid], taxonomy-term--[vocabulary], taxonomy-term.

  • Media: media--[media-type]--[view-mode], media--[media-type], media--[view-mode], media.

Underscores in content type, vocabulary, view and field machine names become hyphens. In the Meridian site the collection_object entity type and its bundles follow the same rule: collection-object--[view-mode].html.twig is the file for a theme hook that the entity type declares.

Where templates live

A theme normally keeps its templates in a templates/ directory, usually with subdirectories by kind (content/, field/, block/, layout/, navigation/, views/). Drupal scans the theme directory recursively and finds a template by file name, not by path, so the subfolders are organisational only: two files with the same name in one theme would conflict. The official naming page does not prescribe the folder; the Starterkit’s layout is the common convention. A module provides its templates in its own templates/ directory and registers the hook with hook_theme() (or its OOP equivalent in 11.x; see Hooks and OOP hooks); the theme can override them with a file of the same name.

A theme override is found in this order: the active theme, then its base themes, then the module or core that defined the hook. Subthemes inherit all templates from their base theme (see Starterkit and subthemes).

web/themes/custom/meridian/
  meridian.info.yml
  meridian.libraries.yml
  meridian.theme
  templates/
    content/node--exhibition--teaser.html.twig
    content/node--exhibition.html.twig
    block/block--meridian-collection-on-display.html.twig
    field/field--field-accession-number.html.twig
    layout/page.html.twig
    navigation/menu--footer.html.twig
  components/
    card/card.component.yml
    card/card.twig

Rules for the registry: after adding a template, rebuild the caches (ddev drush cr); a new template for a hook the theme does not yet override is discovered only then. An override must keep the same variables as the original, because the preprocess functions that feed the original still run. Core’s own templates document those variables at the top of the file, which is why copying the core template as a starting point is the recommended way to begin an override.

A subtheme of Stable 9 (deprecated in 11.4 and removed in Drupal 12) or of Olivero or Claro inherits that base theme’s templates, so before upgrading to 12 the templates it relies on must be copied into your own theme; see Starterkit and subthemes.

Template suggestions from code and Twig

A template suggestion is added in PHP with hook_theme_suggestions_HOOK_alter() and, since 10.0, in Twig itself with the add_suggestion filter:

<?php

declare(strict_types=1);

namespace Drupal\meridian\Hook;

use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\node\NodeInterface;

/**
 * Theme hooks of the meridian theme (web/themes/custom/meridian/src/Hook).
 */
final class ThemeSuggestionHooks {

  public function __construct(private readonly RouteMatchInterface $routeMatch) {}

  /**
   * Adds a page suggestion per content type for node pages.
   */
  #[Hook('theme_suggestions_page_alter')]
  public function pageSuggestions(array &$suggestions, array $variables): void {
    $node = $this->routeMatch->getParameter('node');
    if ($node instanceof NodeInterface) {
      $suggestions[] = 'page__' . $node->bundle();
    }
  }

}

Theme OOP hooks are supported from 11.3 (preprocess from 11.2), and the class must sit in src/Hook. Earlier material, including the page cited, shows the procedural meridian_theme_suggestions_page_alter() in the .theme file, which still works. The suggestion name uses underscores (page__exhibition) and the template is page--exhibition.html.twig. The full treatment of suggestions is in Template suggestions and preprocess.

flowchart LR A["Hook: node"] --> B["Suggestions from preprocess and alter hooks"] B --> C["node--exhibition--teaser"] B --> D["node--exhibition"] B --> E["node--teaser"] B --> F["node"] C -- "file exists in the active theme?" --> G["Use it"] D -- "else" --> G E -- "else" --> G F -- "base template always exists" --> G

Debugging Twig templates

Twig debugging has three parts: showing which template produced each fragment, inspecting variables, and stepping through the code. Enable debugging only on a development site: the official page warns that debug mode can break parts of the site (Views in particular), makes automated tests that compare rendered HTML fail, and adds output to every page.

Enabling debug mode

Since Drupal 10.1 the supported method is the development settings form at /admin/config/development/settings (Configuration > Development > Development settings): tick Twig development mode and, if you want to turn it off separately, Disable Twig cache. The values are stored in the key/value store, not in configuration, so they never reach staging or production; the earlier page Project layout and local development has the full discussion of the form, drush theme:dev and the null cache backends.

# Drush 13.6 and later: Twig debug on, aggregation off, caches cleared.
ddev drush theme:dev on

# Check the effective Twig parameters.
ddev drush php:eval "var_export(\Drupal::getContainer()->getParameter('twig.config'));"

# Compile every template at once, which reveals syntax errors early.
ddev drush twig:compile

# Back to production-like behaviour.
ddev drush theme:dev off

For the old way, editing twig.config in development.services.yml, the same page still documents it. From 10.3 an entry in services.yml overrides the setting made in the form, so a site that carries over an old configuration sees the checkbox seem to do nothing. Remove the entry. auto_reload: true alone enables automatic recompilation without the debug comments, and cache: false disables the Twig cache; Xdebug breakpoints in Twig files require auto_reload to be false.

The debug comments

With Twig debug on, every template output is wrapped in HTML comments that tell you which hook was called, which files Drupal looked for, which one it used and where it is:

<!-- THEME DEBUG -->
<!-- THEME HOOK: 'node' -->
<!-- FILE NAME SUGGESTIONS:
   * node--exhibition--teaser.html.twig
   * node--exhibition.html.twig
   * node--teaser.html.twig
   x node.html.twig
-->
<!-- BEGIN OUTPUT from 'core/modules/node/templates/node.html.twig' -->
<article ...>...</article>
<!-- END OUTPUT from 'core/modules/node/templates/node.html.twig' -->

How to read it:

  1. THEME HOOK is the hook being rendered, and the first piece of information you need to choose a suggestion.

  2. FILE NAME SUGGESTIONS lists candidates from the most to the least specific. The line starting with x is the one in use. A * line is a candidate that was not used. Here the base template is the one in use, so the museum site has no override yet and you can create node--exhibition--teaser.html.twig in the theme.

  3. BEGIN OUTPUT from and END OUTPUT from wrap the markup, and show the path of the file that produced it. Search the page source for THEME HOOK to move quickly. The wrap also shows which fragment belongs to which template, which is the quickest way to tell a module’s markup from the theme’s.

Pitfalls: minifiers and CDNs may strip HTML comments, so debug against the origin; and the comments contain server paths, so a debug-enabled site on the internet leaks its directory structure.

Inspecting variables

{# All top-level variables of the template, as a list of names. #}
<pre>{{ dump(_context|keys) }}</pre>

{# A single variable (can be very large; entities contain recursive references). #}
{{ dump(node.field_accession_number) }}

{# Devel module: collapsible output. #}
{{ devel_dump(content) }}

{# Twig vardumper module #}
{{ vardumper(label) }}

dump() exists only while debug is on; a forgotten call in a template that reaches production makes the template fail to compile, so search for dump( before a release. Dumping an entity is slow and produces a huge page, because the object holds its services and field definitions; dump a field, a value or _context|keys instead. For long investigations the official recommendation is Xdebug.

Stepping through a template with Xdebug

A debugger shows the actual values at each step. PhpStorm can set breakpoints in Twig files directly with no Drupal module, provided auto_reload is false; breakpoints may not stop on the first request after a cache rebuild, so reload once. Devel’s {{ devel_breakpoint() }} and the Twig Xdebug module’s {{ breakpoint() }} are the alternatives for editors without Twig support. Set up Xdebug in DDEV as shown in Project layout and local development.

Compiled templates are PHP classes in the Twig cache directory; opening one shows exactly what Twig made of a template, which helps when a filter chain behaves unexpectedly. drush twig:compile writes them all. Treat the folder as generated code and, on a production server, make sure it is not served by the web server (see the "Generated PHP files and the Twig cache" section of Security hardening).

A debugging routine

  1. Turn on Twig development mode and rebuild the caches.

  2. Log in. Anonymous visitors may still receive cached pages.

  3. View the source and find the fragment by THEME HOOK.

  4. Read the x line to learn the file that renders it.

  5. Copy the file (or the one core template above it) into templates/ with the more specific name, and rebuild the caches once so the registry sees it.

  6. Dump the variables you need, then move the logic that does not belong in the template into a preprocess function.

  7. Remove the dump() calls and turn debug off before committing.

The Twig 3 and 11.x changes

Twig 3.x

Core uses Twig 3.x. Features of Twig 2 that Twig 3 removed or deprecated reach Drupal templates in practice in a few places: the spaceless tag and filter, the {% for x in y if cond %} form (use {% if %} inside the loop or the filter filter), and the sameas and divisibleby tests (use same as and divisible by). Run Upgrade Status on a theme to list these before moving to the next major version.

spaceless removal

Twig deprecated the spaceless filter in 3.12 and plans its removal in Twig 4. Drupal removed its use from core templates in 11.1 (drupal.org issue 3486170); the maintainers decided not to backport that change to 10.5, because that branch will never use Twig 4. Core templates therefore no longer wrap output in {% apply spaceless %}. If your own templates still use {% spaceless %} or {% apply spaceless %}, remove it and handle whitespace with the dash modifiers on the delimiters ({%- and -%}), or accept the whitespace, since HTML collapses it.

{# Before (Twig 2 syntax, a deprecation notice in Twig 3.12 and later) #}
{% spaceless %}
  <ul>
    {% for item in items %}<li>{{ item }}</li>{% endfor %}
  </ul>
{% endspaceless %}

{# After #}
<ul>
  {%- for item in items -%}
    <li>{{ item }}</li>
  {%- endfor -%}
</ul>

twig/html-extra, html_cva and html_classes in 11.4

Drupal 11.4.0 added the twig/html-extra package as a dependency of core and exposes its functions in templates. The change record "Class Variance Authority (CVA) support added to Twig" (11.4.0) describes html_cva(), and the drupal.org issue 3559156 resolution also makes html_classes (and html_attr) available. The record is written for themers and for developers of site templates, recipes and distributions. No Composer step is needed on 11.4; on 11.3 and earlier the functions are not present unless the package and its Twig extension are added.

html_classes joins class names, including those whose key is a condition:

<article class="{{ html_classes(
  'exhibition-teaser',
  {
    'exhibition-teaser--featured': node.isPromoted(),
    'exhibition-teaser--closed': node.field_status.value == 'closed',
  }
) }}">

html_cva() builds a class string from a base and named variants, which matches how components with sizes and colours are written. Create the object once, then call apply() with the chosen variants and any extra classes:

{# web/themes/custom/meridian/components/card/card.twig #}
{% set card_classes = html_cva(
  base: 'card',
  variants: {
    layout: { stacked: 'card--stacked', side: 'card--side' },
    tone: { default: 'card--default', dark: 'card--dark' },
  }
) %}
<article class="{{ card_classes.apply({ layout: layout|default('stacked'), tone: tone|default('default') }, class) }}">
  <h3 class="card__title">{{ title }}</h3>
  {{ content }}
</article>

The Twig documentation lists further options (compound variants, a default variant per variant name, boolean variants) and notes that Twig itself introduced html_cva in 3.12. Before relying on those options, check the documentation page of the exact twig/html-extra version in your composer.lock, because the options evolved between 3.x releases. For plain conditional classes in Drupal templates, html_classes or the Attribute object (addClass) are both fine; the discussion in drupal.org issue 3578007 found that core’s own templates gain little from html_classes, since core prefers to handle attributes in one pass.

Other version notes

  • Classy and Stable were removed in 10.0. Debug output, tutorials and books that show core/themes/classy/templates/... paths describe a base theme that no longer exists.

  • The Standard profile no longer creates Article and Page content types (11.4), so node--article.html.twig and node--page.html.twig from tutorials need a content type that you create first.

  • Stable 9 is deprecated in 11.4, and Claro, Olivero and Stable 9 are removed in Drupal 12. Templates copied from them into a subtheme keep working because they are then your files, but themes that inherit from them lose the base set.

  • From 11.3 theme classes can use OOP hooks and preprocess hooks as attributes; Twig behaviour is unchanged.

Coding style: Twig CS Fixer

Drupal’s Coder and PHPCS rules cover PHP, YAML, CSS and JavaScript, not Twig (see Coding standards and static analysis). The tool most used in the community for templates is Twig CS Fixer (vincentlanglet/twig-cs-fixer), which lints and fixes the spacing and ordering of a Twig file, in the same way PHP CS Fixer does for PHP. Several Drupal contrib projects have adopted it; the search of drupal.org documentation shows no official Drupal-wide Twig standard, so treat the rules as a team convention you choose and document.

ddev composer require --dev vincentlanglet/twig-cs-fixer
ddev exec vendor/bin/twig-cs-fixer lint web/themes/custom/meridian/templates web/themes/custom/meridian/components
ddev exec vendor/bin/twig-cs-fixer lint --fix web/themes/custom/meridian/templates
<?php

// .twig-cs-fixer.php

$finder = new TwigCsFixer\File\Finder();
$finder->in('web/themes/custom/meridian');
$finder->exclude('node_modules');

$ruleset = new TwigCsFixer\Ruleset\Ruleset();
$ruleset->addStandard(new TwigCsFixer\Standard\TwigCsFixer());

$config = new TwigCsFixer\Config\Config();
$config->setFinder($finder);
$config->setRuleset($ruleset);

return $config;

Run the fixer once on the whole theme in a commit of its own, so that later diffs show only real changes, and add the lint command to the CI job that already runs PHPCS and PHPStan for the module. The linter does not know Drupal’s functions, so a project may need to declare them in the configuration. Review the first run’s changes before committing them, since fixers that rewrite spacing in a large .html.twig file are noisy.

Common errors and how to read them

  • "Unknown "xyz" filter/function": a contrib function (Twig Tweak, Devel) is used without its module, or dump is used with debug off. The message names the template and line.

  • "Twig\Sandbox\SecurityError: Calling "x" method on a "y" object is not allowed": the sandbox policy blocked the call. Add a preprocess function or a Twig extension instead of widening the policy.

  • "Variable "x" does not exist" (when strict variables are on): the template uses a variable that preprocess did not provide; check the first comment of the original template for its variables, and spell them exactly.

  • The change does not appear: Twig cache is on, the template was not found by the registry (new file, rebuild), the page is cached for anonymous visitors, or a more specific suggestion wins. Read the debug comments.

  • Output appears escaped, with visible &lt; and &gt;: a string that contains markup was printed without being marked safe. Fix it where the markup is created (use a render array or Markup::create() for trusted content), not with raw in the template.

  • A field prints twice or not at all: |render consumed the render array, or the same field is printed by content and individually; use without.

  • Views breaks with debug on: the official page notes this; test in a view with the setting off.

  • Whitespace or "undefined index" from an override: a copied template that dropped a variable the preprocess function still sets (such as attributes).

  • A link text is not translated: it was a variable or lacked the t filter.

Corrections to older books and tutorials

  • Modernizing Drupal 10 Theme Development (2023) and the Drupal 10 Development Cookbook (2023) explain Twig debugging by adding twig.config with debug, auto_reload and cache: false to development.services.yml. Since 10.1 the development settings form does this, and since 10.3 a twig.config entry in a services file overrides the form. Use the form or drush theme:dev.

  • Both books show templates and debug output based on Classy or Stable (core/themes/classy/templates/...). Classy and Stable were removed in 10.0; the base set is now what starterkit_theme or the chosen base theme provides, and the debug comments show those paths.

  • They write {% spaceless %} or {% apply spaceless %} in examples. Core removed its own use in 11.1 and Twig deprecated the filter in 3.12.

  • They call \Drupal:: services from .theme files and preprocess code. In Drupal 11.2 and later, themes can use OOP hooks and autowired services, and the Twig path(), url(), link() and file_url() functions remove many reasons to touch services in a template.

  • Examples built on the Standard profile’s Article and Page types do not apply to a fresh 11.4 install, which no longer creates them.

  • The books predate html_cva, html_classes, add_class, set_attribute and clean_unique_id; the last three arrived in 10.1 and the first two in 11.4.

  • Drupal 10 itself ends on 2026-12-09; a theme that targets the books' version must be updated to Drupal 11 first. See Where older books and tutorials are outdated.

Summary of what to remember

  • Twig 3.x with autoescaping on; mark output safe only in PHP, never with raw on user data.

  • Use attributes, create_attribute() and Attribute methods for HTML attributes, with no space in front of {{ attributes }}.

  • attach_library() loads assets where components appear. Use path(), url(), link() and file_url() for URLs.

  • Use t/trans for literal strings only, and {% plural %} for plurals.

  • Name templates by hyphens, rebuild caches for a new file, and read THEME DEBUG comments to find the file.

  • Debug with the development settings form or drush theme:dev, never with services files, and never in production.

  • From 11.4, html_cva and html_classes are available. spaceless is gone from core templates since 11.1.

  • Lint Twig with Twig CS Fixer by team convention.