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:
-
A render array or a theme hook call (
'#theme' => 'node') asks the theme system for the hooknode. -
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.
-
Preprocess functions (and OOP hooks, see Hooks and OOP hooks) fill the
variablesarray. -
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/twigfolder), 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.
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.labelcallslabel(). -
node.idcallsid(),node.bundlecallsbundle(). -
node.field_accession_number.valuereads the main property of the first item of a field. -
node.field_gallery.0.target_idreads a property of a specific item. -
node.field_hero.entity.labelfollows an entity reference and calls a method on the referenced entity. -
node.created.valueis a raw timestamp. It is a plain integer, so it needsformat_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>
Source: Working With Twig Templates
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
withoutfilter printscontentminus a child, so that the child can be placed elsewhere. -
The ternary
node.isPromoted() ? 'node--promoted'without an else branch yields an empty value, whichaddClassignores. -
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.
-
forintroduces theloopvariable (loop.index,loop.first,loop.last). Menu templates use it to mark the first and last item. -
includeandembedresolve 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' %}loadspartials/opening-hours.html.twigfrom the thememeridian. 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 %}; theonlykeyword 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 aninclude`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>
Source: Twig Template Naming Conventions
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:
-
tandtransare 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:@nameescapes the value,%nameescapes and emphasises it, and:nameis for URLs and sanitises the protocol. -
The
placeholderfilter escapes a value and wraps it in<em>; it does not translate anything. It is used inside atransblock, 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, insidetrans. -
clean_class: converts any string to a valid CSS class name (it usesHtml::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 HTMLid(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 toclean_idfor ids in repeated components such as cards and accordions. -
without: prints a render array, or anAttributeobject, 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 forfield--details.html.twigfirst. -
add_classandset_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') }} – {{ 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 aspanorbuttonfor menu items using the "nolink" or "button" options, and it adds theis-activeclass when aUrlobject hasset_active_classset. The attributes argument should be anAttributeobject; 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 bothpathandurl. -
file_url(uri): converts a file URI such aspublic://collection/lamp.jpginto 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 anAttributeobject in the template; see the next section. -
active_theme()andactive_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 therenderfilter. -
Built-ins that matter:
include,embed,dump(only in debug mode),date,max,minandrange.
<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>
Source: Functions in Twig templates
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.
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
rawto a value from a field, a form, a query string, a Webform submission or an external API. The same holds forMarkup::create()and for'#markup'in PHP; see Security hardening. -
Use
rawonly 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
|striptagsfilter is not a sanitiser and must not be used to make user HTML safe. Text formats andXss::filter()are the supported tools. -
Autoescaping protects HTML text and attribute values, not URLs. A user-controlled value in
hrefis not stripped of ajavascript:protocol by escaping. Print URLs through the link field formatter,link()or aUrlobject, which handle protocols; for the:nameplaceholder int, 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 theAttributeobject, 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 anotherAttributeobject into this one, joining class lists and replacing other values. -
toArray(),storage()andgetClass(): 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. Usecreate_attribute()for a separate set, orwithoutfor 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 theAttributeobject, or useaddClass. -
Missing attributes on a template override. When you override a core template and delete
{{ attributes }}, contextual links,data-drupal-selectorand JavaScript behaviours attached through them stop working. -
Setting
idtwice. Two components with the same id break anchors and ARIA relationships.clean_unique_idavoids this. -
Attribute values are escaped, not validated.
setAttribute('href', user_value)is escaped, but not stripped ofjavascript: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,hasoris(the default allowed prefixes); -
the methods
id,label,bundle,get,__toStringandtoString(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 thefield-prefix:field--field-accession-number.html.twig. -
Block:
block--[module]--[delta],block--[module],block. A Views block isblock--views-block--[view]-[display]; Layout Builder inline blocks areblock--inline-block--[block-type]. -
Page:
page--frontfor the front page, and one suggestion per path element, with numeric elements dropped from the later ones. For/node/1/editthe list ispage--node--edit,page--node--1,page--nodeandpage. 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
Source: Twig Template Naming Conventions
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();
}
}
}
Source: Working With Twig Templates
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.
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
Source: Debugging Twig templates
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:
-
THEME HOOKis the hook being rendered, and the first piece of information you need to choose a suggestion. -
FILE NAME SUGGESTIONSlists candidates from the most to the least specific. The line starting withxis 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 createnode--exhibition--teaser.html.twigin the theme. -
BEGIN OUTPUT fromandEND OUTPUT fromwrap the markup, and show the path of the file that produced it. Search the page source forTHEME HOOKto 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) }}
Source: Debugging Twig templates
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
-
Turn on Twig development mode and rebuild the caches.
-
Log in. Anonymous visitors may still receive cached pages.
-
View the source and find the fragment by
THEME HOOK. -
Read the
xline to learn the file that renders it. -
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. -
Dump the variables you need, then move the logic that does not belong in the template into a preprocess function.
-
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.twigandnode--page.html.twigfrom 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
dumpis 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
<and>: a string that contains markup was printed without being marked safe. Fix it where the markup is created (use a render array orMarkup::create()for trusted content), not withrawin the template. -
A field prints twice or not at all:
|renderconsumed the render array, or the same field is printed bycontentand individually; usewithout. -
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
tfilter.
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.configwithdebug,auto_reloadandcache: falsetodevelopment.services.yml. Since 10.1 the development settings form does this, and since 10.3 atwig.configentry in a services file overrides the form. Use the form ordrush 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 whatstarterkit_themeor 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.themefiles and preprocess code. In Drupal 11.2 and later, themes can use OOP hooks and autowired services, and the Twigpath(),url(),link()andfile_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_attributeandclean_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
rawon user data. -
Use
attributes,create_attribute()andAttributemethods for HTML attributes, with no space in front of{{ attributes }}. -
attach_library()loads assets where components appear. Usepath(),url(),link()andfile_url()for URLs. -
Use
t/transfor literal strings only, and{% plural %}for plurals. -
Name templates by hyphens, rebuild caches for a new file, and read
THEME DEBUGcomments 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_cvaandhtml_classesare available.spacelessis gone from core templates since 11.1. -
Lint Twig with Twig CS Fixer by team convention.
References
-
Change record: Class Variance Authority (CVA) support added to Twig
-
Remove use of deprecated "spaceless" filter in core templates
-
Luca Lusso, Modernizing Drupal 10 Theme Development (Packt, 2023), and Matt Glaman and Kevin Quillen, Drupal 10 Development Cookbook, 3rd ed. (Packt, 2023), cited by title only for the corrections above.