Twig templates - a drupal cheat sheet
A Drupal Twig cheat sheet: every Twig tag, filter, function, test and operator, plus Drupal's own extras, Twig Tweak, debugging and the gotchas.

Twig is awesome. It gives you a lot of the power of PHP but is far more readable and blends nicely with writing standard HTML. It is fast, secure, and flexible. Twig is used by many open-source projects including Symfony and by extension Drupal. If you’re looking for a cheat sheet for Twig in Drupal, look no further! We cover variables, delimiters, tags, filters, functions, tests & operators, and then everything Drupal adds on top: its own filters and functions, the Attribute object, template naming, debugging, single directory components and the Twig Tweak module.
Along the way I’ve added the tricks, quirks and “why on earth is it doing that?” moments I’ve collected from years of building Drupal themes. Bookmark it, search it with Ctrl/⌘ F, and come back whenever a template is fighting you. Enjoy!
Which versions this covers
This cheat sheet is written for Drupal 10 and 11, which both use Twig 3. Twig has been adding features at a steady pace (Drupal 11.4 requires Twig 3.28 or later), so where something needs a recent version of Twig or Drupal, I’ve said so. To check which version of Twig your site has, run:
composer show twig/twig
Drupal 8 used Twig 1 and Drupal 9 used Twig 2. A few things changed in Twig 3 (the if inside a for loop and the spaceless tag are gone, for example), and old Stack Overflow answers are full of them, so I’ve flagged those too.
Twig variables & delimiters
Twig prints variables and expressions using double curly braces like this: {{ ... }}.
Variables can have attributes or child elements that you can also access using a dot such as {{ var.id }} or {{ item.child }}.
Twig templates have two primary delimiters:
{{ ... }}: outputs a variable or the result of an expression.
{% ... %}: executes statements such as for, if else, set etc.
Here is a full example:
{{ page_title }}
<ul>
{% for item in items %}
<li>
{% if item.primary %}
<strong>{{ item.title }}</strong>
{% else %}
{{ item.title }}
{% endif %}
</li>
{% endfor %}
</ul>
Twig templates also come with an additional delimiter which can be used for comments which looks like {# ... #}.
A common mix-up: the curly braces belong to the print statement, not the variable. Inside a tag you use the variable on its own, so it’s {% if item.primary %}, never {% if {{ item.primary }} %}.
Comments
{# ... #} comments can span several lines and never reach the browser, unlike HTML comments. From Twig 3.15 you can also add inline comments inside an expression with #. They run to the end of the line, so the closing braces have to go on the next line:
{# This comment never reaches the HTML. #}
{{
'Hello'|upper # an inline comment (Twig 3.15+)
}}
Whitespace control
Twig removes the first newline after a tag and leaves every other bit of whitespace alone. That’s usually fine, but it bites when whitespace matters, such as inline elements or a comma-separated list. Add - to a delimiter to remove all whitespace (newlines included) on that side, or ~ to remove spaces and tabs but keep newlines:
<li>
{{- item.title -}}
</li>
{# outputs <li>Title</li> #}
{%- if show_tags -%}
{{ tags }}
{%- endif -%}
To remove the whitespace between two tags, use an empty comment with the trimming modifier on both sides: {#--#}.
Drupal quirk: with Twig debug switched on, Drupal wraps every template in HTML comments, which adds whitespace of its own. If spacing looks different with debug on and off, that’s why.
Accessing attributes and array keys
The dot works on arrays and objects alike. For {{ user.name }}, Twig tries, in order: an array key called name, a property called name, then the methods name(), getName(), isName() and hasName(). If none of those exist, you get null.
{{ node.title.value }} {# dot notation #}
{{ content['field_tags'] }} {# square brackets: needed for keys with hyphens or starting with a number #}
{{ content[field_name] }} {# square brackets with a variable as the key #}
{{ node.('field_' ~ name) }} {# dynamic attribute (Twig 3.15+; the attribute() function before that) #}
{{ node?.label }} {# null-safe: prints nothing if node is null (Twig 3.23+) #}
Drupal quirk: Drupal runs every template in a sandbox that limits which methods you can call on objects. See the Twig sandbox in Drupal further down before you try {{ node.toUrl() }}.
Literals
{% set single = 'Single quotes' %}
{% set double = "Double quotes allow #{interpolation}" %}
{% set number = 1_000_000 %} {# underscores in numbers need Twig 3.17+ #}
{% set list = ['apple', 'pear'] %} {# a sequence #}
{% set map = { colour: 'teal', 'data-id': 7, (key_var): 'dynamic key' } %}
{% set flags = [true, false, null] %}
String interpolation (#{...}) only works in double-quoted strings. Mapping keys don’t need quotes unless they contain characters like hyphens, and you can use a variable as a key by wrapping it in brackets.
Global variables
Twig gives every template _self (the current template’s name), _context (every variable available to the template) and _charset. _context is a handy debugging tool, as you’ll see in the debugging section.
Drupal adds its own defaults to every template:
attributes,title_attributesandcontent_attributes: Attribute objects for the wrapper, title and content.title_prefixandtitle_suffix: where modules put things like contextual links. Keep printing them!directory: the path to the active theme, such asthemes/custom/mytheme. Handy for images:<img src="/{{ directory }}/images/logo.svg" alt="">.logged_in: true if the visitor is logged in.is_admin: true if the visitor has the “access administration pages” permission. It doesn’t mean they have the administrator role!user: the current user’s account.db_is_active: false on the maintenance page.
is_front is only set for the page template. If you need it in a node or block template, add it in a preprocess function.
Undefined variables fail silently
Drupal runs Twig with strict_variables switched off. If you mistype a variable name, Twig prints nothing at all, with no error and no warning. When something isn’t showing up, check the spelling before anything else. To check for a variable on purpose, use is defined or the ?? operator:
{% if subtitle is defined %}{{ subtitle }}{% endif %}
{{ subtitle ?? 'No subtitle' }}
Twig tags
{% apply %}
Applies twig filters on a block of code (rather than a single line of code which use the | syntax). You can chain filters too.
{% apply upper %}
This text becomes uppercase.
{% endapply %}
{% apply lower|escape('html') %}
<strong>SOME TEXT</strong>
{% endapply %}
{% autoescape %}
Applies autoescape on a block of data. Can be combined with a space and an argument such as 'html', 'js', 'css', 'url', 'html_attr' or false.
{% autoescape 'js' %}
var title = '{{ title }}';
{% endautoescape %}
Drupal switches autoescaping on for every template and you should leave it that way. {% autoescape false %} turns off the protection against cross-site scripting (XSS) for everything inside it, so think hard before you use it.
{% block %}
Declares or overrides a twig block. Twig blocks can be named such as {% block named_block %}. Twig blocks can only be overridden when using the {% extends %}, {% embed %} or {% use %} tags in another twig template.
{% block title %}
<h1>{{ label }}</h1>
{% endblock %}
{# Shorthand for a block that prints one thing: #}
{% block subtitle subtitle|upper %}
Twig blocks have nothing to do with Drupal blocks, the things you place in regions. Same word, completely different meaning!
{% cache %}
Caches a fragment of a template under a key, with an optional time to live in seconds: {% cache "some cache key" ttl(5184000) %}.
This one doesn’t work in Drupal. The cache tag comes from the separate twig/cache-extra package, which Drupal doesn’t include, so you’ll get an “Unknown tag” error if you copy it from a Symfony example. Drupal has its own, much more capable caching through render arrays and cache metadata (tags, contexts and max-age). If a part of a template is expensive, cache it in PHP: move it into a render array with a #cache key or a lazy builder. See Cache API (opens in a new tab) on drupal.org.
{% deprecated %}
Declares a deprecation notice for something in your template to help contributors migrate when changes are made. Must be combined with a deprecation notice in quotes. For example: {% deprecated 'The "page.twig" template is deprecated. Please use the "node.twig" template instead.' %}
From Twig 3.11 you can add the package and version: {% deprecated 'Use card.html.twig instead.' package='mytheme' version='2.0' %}. The notice is triggered when the template compiles, so it shows up in your logs and test runs, not on the page.
{% do %}
Performs an operation but doesn’t print it. For example {% do 2 + 2 %}. This might be useful if you want to perform an operation on an array in a loop based on a condition or something similar, without printing the result.
In Drupal this is the neat way to change an Attribute object without printing it:
{% do attributes.addClass('card', 'card--' ~ view_mode) %}
{% do attributes.setAttribute('data-id', node.id) %}
<article{{ attributes }}>
...
</article>
From Twig 3.23 you can also use do with the = operator to set variables, including destructuring: {% do [first, last] = ['Darren', 'Fisher'] %}.
{% embed %}
Calls another twig template and allows you to pass variables using the with keyword and override any declared twig blocks in the template you are embedding. Here is a full example:
{% embed '@mytheme/components/card.html.twig' with {
card_heading: heading,
card_copy: copy,
card_link: link,
} %}
{% block card_img %}
<img src="{{ image.src }}" alt="{{ image.alt }}"/>
{% endblock %}
{% endembed %}
Like include, the embedded template sees all the current template’s variables as well as the ones you pass. Add only after the with mapping to pass just those. In Drupal, embed is also how you fill the slots of a single directory component.
{% extends %}
Calls another twig template and allows you to override any declared twig blocks in the template you are extending, but you cannot pass variables using the with keyword and you cannot add any content outside of the blocks, making this suitable for vertical inheritance where you want to override some blocks from a parent template. Here is a full example:
{% extends '@mytheme/layout.html.twig' %}
{% block title %}
{{ page_title }}
{% endblock %}
{% block content %}
{{ content }}
{% endblock %}
{% block sidebar %}
{{ parent() }}
{{ sidebar_region }}
{% endblock %}
{{ parent() }} prints the parent template’s version of the block, so you can add to it instead of replacing it. Since Twig 3.28, putting extends inside an if or another tag is deprecated. To choose a parent template conditionally, use an expression instead: {% extends featured ? '@mytheme/layouts/featured.html.twig' : '@mytheme/layouts/default.html.twig' %}.
In Drupal, extending a template from another theme or module is a great way to override one block of a template without copying the whole file. For example, a theme can extend one of core’s Olivero templates with {% extends '@olivero/content/node.html.twig' %} and change a single block. The parent template has to declare blocks for this to work, and plenty of core templates have few or none.
Drupal trick: Drupal looks up plain template names such as 'node.html.twig' in the theme registry. So in node--article.html.twig, {% extends 'node.html.twig' %} extends whichever node.html.twig your site is using (your theme’s, or your base theme’s), and your article template only has to override the blocks that differ.
{% flush %}
Calls PHP’s flush() function, which in theory sends everything rendered so far to the browser so visitors can start reading a long page while the rest is still rendering.
Don’t bother with it in Drupal. Drupal renders each template into a string as part of a render array and only sends the response once the whole page is built, so flush has nothing useful to send. If part of a page is slow, Drupal’s answer is BigPipe (opens in a new tab) (in core and switched on in the standard profile) together with lazy builders, which stream slow, personalised parts of the page in after the rest.
{% for %}
Initiates a loop which iterates over items in an array. Once inside the loop you have access to each object’s values (if applicable) and a special loop object that gives you some useful variables.
Here is an example:
{% for item in items %}
<div>
<h3>{{ item.title }}</h3>
<p>{{ item.description }}</p>
<a href="{{ item.link_url }}">{{ item.link_text }}</a>
</div>
{% else %}
<p>{{ 'Nothing to show yet.'|t }}</p>
{% endfor %}
The else part runs when there’s nothing to loop over.
The special loop object variables are as follows:
loop.indexwhich returns the current index of the loop (starting from 1).loop.index0which returns the current iteration of the loop (starting from 0).loop.revindexwhich returns the number of iterations until the end of the loop (starting from 1).loop.revindex0which returns the number of iterations until the end of the loop (starting from 0).loop.firstwhich returns true if the first iteration of the loop, otherwise returns false.loop.lastwhich returns true if the last iteration of the loop, otherwise returns false.loop.lengthwhich returns the number of items in the overall sequence.loop.parentwhich returns the parent context.
loop.length, loop.last, loop.revindex and loop.revindex0 aren’t available when you loop over an iterator that can’t be counted.
You can loop over keys and values, a range of numbers, or letters:
{% for key, value in mapping %}{{ key }}: {{ value }}{% endfor %}
{% for i in 1..5 %}{{ i }}{% endfor %}
{% for letter in 'a'..'e' %}{{ letter }}{% endfor %}
Changed in Twig 3: {% for item in items if item.active %} no longer works. Use the filter filter instead: {% for item in items|filter(i => i.active) %}. You also can’t break or continue out of a Twig loop, so filter the items before the loop starts.
Loops have their own scope. A variable you create inside a loop disappears when the loop ends, but if you create it before the loop, you can change it inside:
{% set total = 0 %}
{% for item in items %}
{% set total = total + item.price %}
{% endfor %}
{{ total }}
{% from %}
Imports macro names into the current namespace. See the {% macro %} tag for more information.
{% from '@mytheme/macros/forms.html.twig' import input, textarea as text_area %}
{{ input('name') }}
{% guard %}
New in Twig 3.15. Checks whether a function, filter or test exists when the template compiles, and lets you provide a fallback. This is gold for contributed themes and starter kits that need to support several versions of Drupal:
{% guard function html_classes %}
<div class="{{ html_classes('card', { 'card--featured': featured }) }}">
{% else %}
<div class="card{{ featured ? ' card--featured' }}">
{% endguard %}
{% if %}
Runs a block of code only when a condition is true, with optional elseif and else branches. You can combine conditions with and, or and not, and use any test or operator.
{% if node.isPublished() and not node.isPromoted() %}
...
{% elseif user.hasPermission('administer nodes') %}
...
{% else %}
...
{% endif %}
Twig follows PHP’s rules for truthiness: an empty string, 0, '0', an empty array, null and false are all false.
Drupal quirk: {% if content.field_image %} is almost always true, even when the field is empty, because the render array still carries cache metadata. See is this field empty? for the fix.
You can also use if inline as a ternary: {{ featured ? 'Featured' : 'Standard' }}, and leave out the else part: {{ featured ? 'Featured' }}.
{% import %}
Imports macros from a template into a variable, so you call them like methods. Macros in the same template are available through _self automatically.
{% import '@mytheme/macros/forms.html.twig' as forms %}
{{ forms.input('email', '', 'email') }}
{# Macros defined in this template: #}
{{ _self.input('name') }}
{% include %}
Includes another template and prints its output. The included template sees all the current variables, and you can pass more with with. Add only to pass nothing but the variables you list, which I’d recommend for anything you reuse: it stops the included template depending on whatever happened to be lying around.
{% include '@mytheme/partials/teaser.html.twig' %}
{% include '@mytheme/partials/teaser.html.twig' with { title: label } %}
{% include '@mytheme/partials/teaser.html.twig' with { title: label } only %}
{% include '@mytheme/partials/promo.html.twig' ignore missing %}
{% include ['@mytheme/partials/teaser--' ~ bundle ~ '.html.twig', '@mytheme/partials/teaser.html.twig'] %}
ignore missing prints nothing if the file doesn’t exist. A list of templates uses the first one that exists.
There’s also an include() function that does the same thing and returns the output, so you can filter it or store it: {% set teaser = include('@mytheme/partials/teaser.html.twig', { title: label }, with_context = false) %}. From Twig 3.29 the include_only() function is a shortcut for the version without the current context.
In Drupal, use the @theme_or_module_name/ namespace prefix to include a file from any theme or module. See including templates from other themes and modules.
{% macro %}
Defines a reusable chunk of template that takes arguments, like a function. Macros are handy for repeated markup that’s too small to be worth its own template.
{% macro input(name, value = '', type = 'text') %}
<input type="{{ type }}" name="{{ name }}" value="{{ value }}">
{% endmacro %}
{{ _self.input('email', '', 'email') }}
Two gotchas. First, a macro can’t see the template’s variables, only its arguments, so pass in everything it needs. Second, recent versions of Twig are tightening up the rules: since Twig 3.29, calling a macro without parentheses, with the wrong case, or with an argument it doesn’t declare triggers a deprecation notice, and these become errors in Twig 4. Always call macros with brackets.
In Drupal, a macro is the standard way to render nested menus, because a macro can call itself. Core’s menu.html.twig does exactly this.
{% sandbox %}
Runs an included template under the sandbox’s security policy. It was deprecated in Twig 3.15 and is pointless in Drupal anyway, because Drupal already sandboxes every template. Skip it.
{% set %}
Creates or changes a variable. You can set several at once, or capture a chunk of output with the block form:
{% set heading = label|upper %}
{% set first, last = 'Darren', 'Fisher' %}
{% set classes = ['card', featured ? 'card--featured'] %}
{% set byline %}
{{ 'By'|t }} <span class="author">{{ author_name }}</span>
{% endset %}
{{ byline }}
The block form is safe to print: Twig treats the captured output as already escaped.
To add to an array, merge a new one in. Twig arrays can’t be changed in place, so you reassign the variable:
{% set classes = classes|merge(['card--' ~ view_mode|clean_class]) %}
From Twig 3.23 you can also set variables with the = operator inside expressions, and split sequences and mappings into separate variables (destructuring):
{% do [first, , third] = ['a', 'b', 'c'] %}
{% do { title, url: link } = item %} {# renames url to link (Twig 3.24+) #}
Variables you set inside an included template or a loop stay there. They don’t leak back into the template that called them.
{% trans %} (Drupal)
Drupal’s own tag for translatable text, with placeholders and plurals. Variables inside are escaped and turned into @ placeholders; add the placeholder filter to get a % placeholder, which Drupal wraps in <em class="placeholder">.
{% trans %}Submitted by {{ author_name }} on {{ date }}{% endtrans %}
{% trans %}
Found one result for {{ query|placeholder }}.
{% plural count %}
Found {{ count }} results for {{ query|placeholder }}.
{% endtrans %}
{% trans with { context: 'Long month name' } %}May{% endtrans %}
Keep the text inside trans simple: plain text and printed variables only. Attributes such as {{ node.label }} and filters are fine, but tags such as if, function calls and ~ joins aren’t, and you’ll get “The text to be translated with “trans” can only contain references to simple variables”. Set a variable before the tag instead.
{% types %}
Experimental, added in Twig 3.13. Declares the types of the variables a template expects. Twig doesn’t check them at run time; it’s documentation for people and for editors that understand it.
{% types {
title: 'string',
featured: 'bool',
image?: 'string',
} %}
{% use %}
Imports the blocks from another template so you can use them in this one, without extending it. It’s called horizontal reuse, and it’s useful when you want blocks from several templates at once. You can rename blocks as you import them.
{% extends '@mytheme/layout.html.twig' %}
{% use '@mytheme/blocks/cards.html.twig' with card as base_card %}
{% block card %}
<div class="card--wrapper">{{ block('base_card') }}</div>
{% endblock %}
{% verbatim %}
Outputs everything inside it as raw text without Twig parsing it. Use it when you need to print something that looks like Twig, such as a JavaScript template or a code sample:
{% verbatim %}
<script type="text/x-template">{{ message }}</script>
{% endverbatim %}
{% with %}
Creates an inner scope. Variables you set inside disappear at endwith. Add a mapping to add variables, and only to hide everything else:
{% with { title: 'Teaser' } only %}
{{ title }} {# Teaser #}
{{ label }} {# nothing: hidden by "only" #}
{% endwith %}
Filters
Filters change a value. They follow a pipe (|) and can be chained: {{ name|striptags|title }}. Filters bind more tightly than any other operator, so wrap complex expressions in brackets: {{ ('Hello ' ~ name)|upper }}.
Several filters take an arrow function: a short function written item => item.price, with brackets around more than one argument: (item, key) => key != 'skip'.
abs
Returns the absolute value of a number: {{ -7|abs }} gives 7… except it doesn’t! The filter binds more tightly than the minus sign, so that’s -(7|abs), which is -7. Write {{ (-7)|abs }}.
batch
Splits a sequence into groups of a given size, optionally padding the last group. Perfect for building rows:
{% for row in items|batch(3, '') %}
<div class="row">
{% for item in row %}<div class="col">{{ item }}</div>{% endfor %}
</div>
{% endfor %}
capitalize
Uppercases the first character and lowercases the rest: {{ 'hELLO world'|capitalize }} gives “Hello world”.
column
Returns the values of one key from every item in a sequence: {{ products|column('price')|join(', ') }}.
convert_encoding
Converts a string from one character encoding to another: {{ data|convert_encoding('UTF-8', 'iso-2022-jp') }}. Needs PHP’s iconv or mbstring extension.
date
Formats a date or timestamp with PHP date format characters: {{ 'now'|date('j F Y') }}, {{ timestamp|date('Y-m-d', 'Europe/London') }}. The default format is F j, Y H:i.
In Drupal, prefer format_date. Twig’s date doesn’t translate month and day names, and doesn’t know about Drupal’s date formats.
date_modify
Changes a date: {{ 'now'|date_modify('+1 week')|date('j F Y') }}.
default
Returns a fallback when the value is undefined or empty: {{ subtitle|default('No subtitle') }}. “Empty” means an empty string, empty array, null or false; 0 isn’t empty.
Drupal quirk: {{ content.field_subtitle|default('...') }} never shows the fallback, because a Drupal render array is never empty.
escape (or e)
Escapes a value for a particular context: html (the default), js, css, url or html_attr (for attribute values without quotes). Drupal already escapes everything you print for HTML, so you only need this for other contexts:
<script>var searchTerm = '{{ query|e('js') }}';</script>
<a href="/search/?q={{ query|e('url') }}">
Drupal replaces Twig’s escape filter with its own version that understands render arrays and Drupal’s safe markup objects.
filter
Keeps the items for which an arrow function returns true. The function receives the value and, optionally, the key:
{% for product in products|filter(p => p.in_stock) %}...{% endfor %}
{% for key, child in content.field_tags|filter((v, k) => k|first != '#') %}...{% endfor %}
find
New in Twig 3.11. Returns the first item for which an arrow function returns true, or null: {% set lead = people|find(p => p.role == 'Director') %}.
first
Returns the first item of a sequence or mapping, or the first character of a string: {{ [1, 2, 3]|first }}.
format
Fills placeholders in a string, like PHP’s sprintf: {{ '%s has %d items'|format(name, count) }}.
Don’t use it for text people read in Drupal: use t with placeholders so the text can be translated.
invoke
New in Twig 3.19. Calls an arrow function stored in a variable: {% set double = x => x * 2 %}{{ double|invoke(4) }}.
join
Joins the items of a sequence into a string, with an optional different glue before the last item:
{{ ['Drupal', 'Twig', 'Symfony']|join(', ', ' and ') }}
{# Drupal, Twig and Symfony #}
In Drupal, use safe_join when the items might be render arrays or markup.
json_encode
Converts a value to JSON: {{ settings|json_encode }}. Add |raw only when printing straight into a <script> tag, and only with data you trust. For passing settings to JavaScript, Drupal’s drupalSettings (set in PHP) is the better route.
keys
Returns the keys of a mapping: {% for key in _context|keys %}{{ key }} {% endfor %}.
last
Returns the last item of a sequence or mapping, or the last character of a string: {{ breadcrumbs|last }}.
length
Returns the number of items in a sequence or mapping, or the number of characters in a string: {% if items|length > 3 %}.
Drupal quirk: on a render array, length counts every key, including the # properties, so content.field_tags|length isn’t the number of tags. Count the field instead: {{ node.field_tags|length }}.
lower
Lowercases a string: {{ 'HELLO'|lower }}.
map
Applies an arrow function to every item: {{ people|map(p => p.first ~ ' ' ~ p.last)|join(', ') }}. Like filter, it receives the key too.
merge
Merges two sequences, or two mappings. In mappings, later keys overwrite earlier ones:
{% set classes = ['card']|merge(['card--wide']) %}
{% set options = { size: 'small' }|merge({ colour: 'teal' }) %}
nl2br
Converts newlines to <br> tags. It escapes the string first, so it’s safe: {{ address|nl2br }}.
number_format
Formats a number with grouped thousands: {{ 1234.5|number_format(2, '.', ',') }} gives 1,234.50.
raw
Marks a value as safe so Twig doesn’t escape it. Use it as little as possible. Anything you print with |raw that contains user input is a cross-site scripting (XSS) hole. In Drupal you rarely need it: rendered fields, t() output and other Drupal markup are already marked safe. If you find yourself reaching for |raw, fix the variable in a preprocess function instead (for example, with Markup::create() on markup you’ve built and checked).
reduce
Reduces a sequence to a single value with an arrow function and a starting value: {{ items|reduce((total, item) => total + item.price, 0) }}.
replace
Replaces parts of a string using a mapping: {{ 'I like %this%'|replace({'%this%': fruit}) }}.
reverse
Reverses a sequence, mapping or string: {{ items|reverse }}. Pass true to keep the keys.
round
Rounds a number to a given precision, using common (the default), ceil or floor: {{ 42.55|round(1, 'floor') }} gives 42.5.
shuffle
New in Twig 3.11. Shuffles a sequence or string randomly. Watch out in Drupal: the result is cached with the rest of the template, so “random” stays the same until the cache clears. Randomise in JavaScript instead.
slice
Extracts part of a sequence or string. There’s a shorthand using square brackets:
{{ items|slice(0, 3) }} {# the first three #}
{{ items[0:3] }} {# the same #}
{{ 'Twig'[1:] }} {# wig #}
sort
Sorts a sequence, optionally with an arrow function using the <=> operator:
{% for product in products|sort((a, b) => a.price <=> b.price) %}...{% endfor %}
Keys are kept, which matters if you then loop with key, value.
spaceless
Removed whitespace between HTML tags. Deprecated in Twig 3.12 and removed in Twig 4. Use the whitespace control modifiers instead.
split
Splits a string into a sequence: {{ 'one,two,three'|split(',') }}. An empty delimiter splits into chunks of a given size: {{ 'abcd'|split('', 2) }} gives ['ab', 'cd'].
striptags
Strips HTML tags and collapses whitespace. You can keep some tags: {{ body|striptags('<p><a>') }}.
title
Capitalises the first letter of every word: {{ 'my first car'|title }} gives “My First Car”.
trim
Trims whitespace (or other characters) from the start and end of a string, or one side: {{ ' hello '|trim }}, {{ '--hello--'|trim('-', 'left') }}.
upper
Uppercases a string: {{ 'hello'|upper }}. For uppercase headings, CSS text-transform: uppercase is usually better, because screen readers read some uppercase words letter by letter.
url_encode
Encodes a string for a URL, or turns a mapping into a query string: {{ { q: query, page: 2 }|url_encode }} gives q=...&page=2.
Functions
Functions are called by name with brackets: {{ range(1, 5) }}. Since Twig 3.12, every function, filter and test accepts named arguments, which make long calls easier to read: {{ range(low: 1, high: 10, step: 2) }}.
attribute
Gets an attribute of an object or array using a dynamic name: {{ attribute(node, 'field_' ~ name) }}. Deprecated in Twig 3.15: use the dot with brackets instead, {{ node.('field_' ~ name) }}.
block
Prints a block from the current template (or another one) by name: {{ block('title') }}. Use it with the defined test to check for a block: {% if block('footer') is defined %}.
constant
Returns the value of a PHP constant, or a class constant: {{ constant('Drupal\\node\\NodeInterface::PUBLISHED') }}. Note the double backslashes.
cycle
Cycles through a list of values, which is handy for alternating classes: <tr class="{{ cycle(['odd', 'even'], loop.index0) }}">.
date
Turns a value into a date object so you can compare dates: {% if date(node.created.value) < date('-30 days') %}Older than a month{% endif %}.
dump
Prints information about a variable (or every variable, with no arguments). In Drupal it only works when Twig debug mode is on, which keeps it from leaking onto a live site. See dumping variables.
enum and enum_cases
New in Twig 3.15 and 3.12. Work with PHP enums: {{ enum('App\\Status').Active.value }} and {% for case in enum_cases('App\\Status') %}. You won’t need them often in Drupal, but they’re there.
include and include_only
The function versions of the include tag. They return the output, so you can filter or store it. include_only() (Twig 3.29) doesn’t pass the current variables.
max and min
Return the largest or smallest value: {{ max(1, 3, 2) }}, {{ min([4, 2, 8]) }}.
parent
Inside an overridden block, prints the parent template’s version of that block: {{ parent() }}.
random
Returns a random item, character or number: {{ random(['teal', 'black', 'white']) }}, {{ random(10) }}. As with shuffle, Drupal’s render cache will freeze the result.
range
Returns a sequence of numbers or letters, with an optional step: {% for year in range(2007, 'now'|date('Y')) %}. The .. operator is shorthand for it.
source
Returns the raw contents of a file without rendering it as Twig. This is my favourite trick for inline SVG icons, because Drupal lets Twig load .svg files:
<button class="menu-toggle">
{{ source('@mytheme/images/icons/menu.svg') }}
<span class="visually-hidden">{{ 'Menu'|t }}</span>
</button>
The SVG goes straight into the HTML, so you can style it with CSS (fill: currentColor) and it doesn’t need another request. Pass ignore_missing = true to print nothing if the file isn’t there. Only use it for your own files: source() prints the file exactly as it is, without escaping.
template_from_string
Renders a string as a Twig template. Not available in Drupal: it needs Twig’s StringLoader extension, which Drupal doesn’t load. That’s a good thing; rendering Twig from a string is risky.
Tests
Tests check a value and return true or false. Use them with is and is not: {% if count is odd %}.
constant
Checks whether a value equals a constant: {% if status is constant('Drupal\\node\\NodeInterface::PUBLISHED') %}.
defined
Checks whether a variable or attribute exists: {% if subtitle is defined %}, {% if node.field_subtitle is defined %}. Remember that undefined variables print nothing in Drupal, so this is how you tell “missing” from “empty”.
divisible by
Checks whether a number divides exactly: {% if loop.index is divisible by(3) %}</div><div class="row">{% endif %}.
empty
Checks for an empty string, empty array, null or false: {% if items is empty %}. 0 and '0' aren’t empty.
Drupal quirk: content.field_image is empty is false even when the field has no image. See is this field empty?
even and odd
Check whether a number is even or odd: {% if loop.index is even %}.
iterable
Checks whether a value can be looped over: {% if items is iterable %}.
mapping and sequence
New in Twig 3.11. Check whether a value is a mapping (an array with keys, such as { a: 1 }) or a sequence (a list, such as [1, 2]).
null (or none)
Checks for null: {% if subtitle is null %}.
same as
Checks whether two values are identical, the same as PHP’s ===: {% if count is same as(false) %}. From Twig 3.23 you can write count === false instead.
Operators
Maths
+, -, *, /, // (divide and round down), % (remainder) and ** (power). For example, {{ 20 // 7 }} gives 2 and {{ 2 ** 3 }} gives 8.
Logic
and, or, not, and xor (Twig 3.15). There are also bitwise operators: b-and, b-or and b-xor. Twig’s operators are case sensitive, so it’s and, not AND.
Comparison
==, !=, <, >, <= and >= compare loosely, like PHP. From Twig 3.23, === and !== compare strictly (the same as the same as test). The spaceship operator <=> returns -1, 0 or 1, which is what sort needs.
Containment
in and not in check whether a value is in a sequence, mapping or string:
{% if 'editor' in user.getRoles() %}...{% endif %}
{% if 'Drupal' in title %}...{% endif %}
in compares loosely, so 0 in ['a'] can surprise you. When types matter, compare strictly.
Strings
~ joins strings: {{ 'node--' ~ node.bundle }}. starts with, ends with and matches (a regular expression) check them:
{% if url starts with 'https://' %}...{% endif %}
{% if filename ends with '.pdf' %}...{% endif %}
{% if postcode matches '/^[A-Z]{1,2}\\d/' %}...{% endif %}
Iterables
has some and has every check the items of a sequence with an arrow function:
{% if products has some p => p.on_sale %}<span>Sale on!</span>{% endif %}
{% if products has every p => p.in_stock %}...{% endif %}
For an empty sequence, has every is true and has some is false.
Tests
is and is not apply a test: {% if count is not empty %}.
Everything else
..makes a range:1..5.|applies a filter..and[]get an attribute or key;?.does the same but returns null instead of failing when the left side is null (Twig 3.23).?:is the ternary operator, anda ?: b(the “Elvis” operator) returnsaif it’s truthy, otherwiseb.??returns the left side if it’s defined and not null, otherwise the right:{{ subtitle ?? label }}....spreads a sequence or mapping into another, or into a function’s arguments:{% set all = [...defaults, ...extras] %}(Twig 3.15).=>creates an arrow function.=assigns a value inside an expression (Twig 3.23).
Precedence gotchas
When in doubt, add brackets. Twig 3.15 started warning about some combinations that will change in Twig 4: mixing ?? with other operators, ~ with + or -, and not with *, /, // or %. Brackets make the intent clear and keep you ready for Twig 4:
{{ (subtitle ?? 'Untitled') ~ ' | ' ~ site_name }}
{{ 'Total: ' ~ (price + vat) }}
Drupal’s own Twig filters
Drupal adds these filters to every template.
t and trans
Translates interface text with Drupal’s t() function. trans is an alias. Use placeholders for anything that changes: @ escapes the value, % escapes it and wraps it in <em class="placeholder">, and : is for URLs in attributes:
{{ 'Read more'|t }}
{{ 'Submitted by @name on @date'|t({ '@name': author_name, '@date': date }) }}
{{ 'Read the <a href=":url">accessibility statement</a>.'|t({ ':url': path('entity.node.canonical', { node: 12 }) }) }}
{{ 'May'|t({}, { context: 'Long month name' }) }}
Only translate literal strings. {{ some_variable|t }} can’t be picked up for translation and is unsafe if the variable contains user input. Translate content (fields) with Drupal’s content translation instead.
placeholder
Escapes a value and wraps it in <em class="placeholder">. It’s mainly for {% trans %} tags, where it turns a variable into a % placeholder: {% trans %}Searched for {{ query|placeholder }}{% endtrans %}.
escape, e and drupal_escape
Drupal replaces Twig’s escape filter with one that understands render arrays and Drupal’s safe markup objects. You’ll rarely call it yourself: autoescaping does it for every {{ }}.
safe_join
Joins items with a separator, escaping each one unless it’s already safe markup. It renders render arrays too, which Twig’s join can’t:
{{ [author_name, date]|safe_join(' | ') }}
{{ content.field_tags|children|safe_join(', ') }} {# children comes from Twig Tweak #}
The separator isn’t escaped, so never use user input as the separator.
without
Returns a copy of a render array without the keys you list, leaving the original untouched. The classic use is printing a node’s content without the fields you’ve printed elsewhere:
<div class="node__media">{{ content.field_image }}</div>
<div class="node__body">{{ content|without('field_image', 'links') }}</div>
It works on Attribute objects too: <div{{ attributes|without('id') }}>.
clean_class
Turns a string into a valid CSS class name: lowercase, with spaces and underscores as hyphens. {{ 'Featured Article'|clean_class }} gives featured-article.
clean_id
Turns a string into a valid HTML ID: {{ 'Contact Form'|clean_id }} gives contact-form. It doesn’t make the ID unique on the page; for that, use clean_unique_id.
clean_unique_id
Added in Drupal 10.1. Like clean_id, but adds a suffix (--2, --3…) when the same ID has already been used on the page. Perfect for templates that appear more than once, such as an accordion with aria-controls:
{% set panel_id = 'faq-panel'|clean_unique_id %}
<button aria-controls="{{ panel_id }}" aria-expanded="false">{{ question }}</button>
<div id="{{ panel_id }}" hidden>{{ answer }}</div>
add_class
Added in Drupal 10.1. Adds classes to a render array’s wrapper, so you can style a field from the node template without overriding the field template:
{{ content.field_image|add_class('card__image') }}
{{ content.body|add_class('prose', 'card__body') }}
It works on render arrays, not on Attribute objects (use attributes.addClass() for those) or plain strings.
set_attribute
Added in Drupal 10.1. Sets any HTML attribute on a render array’s wrapper:
{{ content.field_gallery|set_attribute('data-gallery', 'main') }}
{{ content.body|set_attribute('id', 'main-content') }}
render
Renders a render array into markup. Use it when you need the rendered output itself, for example to test whether it’s empty, or to strip its tags:
{% set summary = content.body|render|striptags|trim %}
Rendering is the expensive part, so don’t render the same thing twice: store it in a variable, as above, and print the variable.
format_date
Formats a Unix timestamp using Drupal’s date formats, in the visitor’s timezone and language. Use a format’s machine name, or custom with PHP date characters:
{{ node.created.value|format_date }}
{{ node.created.value|format_date('short') }}
{{ node.changed.value|format_date('custom', 'j F Y') }}
For date fields, see dates.
add_suggestion
Adds a template suggestion to a render array from inside a template, so it uses a different template. Hyphens in the suggestion are turned into underscores for you:
{{ content.field_tags|add_suggestion('inline-list') }}
{# Now looks for field--inline-list.html.twig before field.html.twig #}
The render array needs a #theme key, which fields, entities and most elements have.
Drupal’s own Twig functions
active_theme and active_theme_path
Return the machine name of the active theme and its path: {{ active_theme() }} gives mytheme, and {{ active_theme_path() }} gives themes/custom/mytheme.
attach_library
Attaches a CSS/JS library, defined in your theme’s *.libraries.yml, to the page, only when the template is used: {{ attach_library('mytheme/carousel') }}. It prints nothing. This is the best way to load a component’s CSS and JavaScript only on pages that have the component. Calling it more than once is fine: Drupal only adds a library once.
create_attribute
Creates a new Attribute object, for elements inside your template that need their own attributes:
{% set link_attributes = create_attribute({ class: ['card__link'] }) %}
<a{{ link_attributes.setAttribute('href', url) }}>{{ label }}</a>
file_url
Turns a file URI such as public://images/photo.jpg into a relative URL: {{ file_url(node.field_image.entity.uri.value) }}.
link
Creates a link render array from text and a URL object or URI string, with optional attributes:
{{ link(item.title, item.url) }}
{{ link('Contact us'|t, 'internal:/contact', { class: ['button'] }) }}
{{ link('Home'|t, 'route:<front>') }}
A URI string needs a scheme: internal:/path, entity:node/1, route:<front> or a full https:// address. A plain /contact won’t work. Unlike a hand-written <a>, it handles menu links set to <nolink> and <button>.
path and url
Generate a relative (path) or absolute (url) URL from a route name and parameters, with options such as a query string or fragment:
{{ path('<front>') }}
{{ path('entity.node.canonical', { node: 12 }) }}
{{ path('entity.taxonomy_term.canonical', { taxonomy_term: term.id }) }}
{{ path('view.blog.page_1', {}, { query: { page: 2 }, fragment: 'results' }) }}
{{ url('<current>') }}
Not sure of a route’s name? drush route lists them all, or run drush route --path=/node/1 for a single path.
render_var
Renders a render array, string or object. Drupal wraps every {{ }} in it behind the scenes, so you’ll rarely call it. It’s occasionally handy for a quick inline render array: {{ render_var({ '#markup': '<p>Hello</p>' }) }}.
icon
Added in Drupal 11.1 with the Icon API. Renders an icon from an icon pack declared in a module or theme’s *.icons.yml file, with optional settings: {{ icon('mytheme_icons', 'arrow-right', { size: 24 }) }}. If the pack or icon doesn’t exist, it prints nothing instead of failing.
help_route_link and help_topic_link
Added by the Help module for help topic templates: they link to a route or another help topic only if the current user can access it. You won’t need them in a theme.
html_classes, html_cva and html_attr
Added in Drupal 11.4, which ships Twig’s HTML extension. html_classes builds a class list from strings and conditions:
<div class="{{ html_classes('card', { 'card--featured': featured, 'card--dark': dark }) }}">
html_cva implements the Class Variance Authority pattern, popular with Tailwind and component libraries, for components with variants:
{% set button = html_cva(
base: 'button',
variants: {
variant: { primary: 'button--primary', secondary: 'button--secondary' },
size: { sm: 'button--sm', lg: 'button--lg' },
},
default_variant: { variant: 'primary' }
) %}
<button class="{{ button.apply({ variant, size }, extra_classes) }}">{{ label }}</button>
html_attr renders a set of attributes from one or more mappings, merging and escaping them: <button {{ html_attr({ class: ['button'], disabled: is_disabled, 'aria-pressed': pressed }) }}>. It leaves out null values, prints true as an empty attribute and turns booleans into "true" or "false" for aria-* attributes. Use it for new elements; where Drupal already gives you an attributes object, keep using that. The same extension adds the data_uri, html_attr_merge and html_attr_type filters.
The Attribute object
Most Drupal templates get an attributes variable, and many get title_attributes and content_attributes too. They’re Attribute objects: a collection of HTML attributes that modules, themes and preprocess functions have been adding to. Print them straight into the tag, with no space before them; the object adds its own leading space:
<article{{ attributes.addClass('card') }}>
<h2{{ title_attributes.addClass('card__title') }}>{{ label }}</h2>
<div{{ content_attributes }}>{{ content }}</div>
</article>
The methods you can call:
addClass('a', 'b')oraddClass(['a', 'b']): adds classes (duplicates are ignored, and empty values in an array are skipped).removeClass('a'): removes classes.hasClass('a'): checks for a class.setAttribute('name', 'value'): sets an attribute.removeAttribute('name'): removes attributes.hasAttribute('name'): checks for an attribute.getClass(): returns the class attribute.toArray(): returns everything as an array.merge(other): merges another Attribute object in.
The methods return the object, so you can chain them: attributes.addClass('card').setAttribute('id', 'main'). You can also read a single attribute with {{ attributes.id }} or print the classes with {{ attributes.class }}.
Core templates build their class lists like this, and it’s a pattern worth copying. Empty values (from a ternary with no else) are skipped:
{% 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) }}>
The big gotcha: these methods change the object itself, not a copy. If you call attributes.addClass('a') on one element and then print attributes on another, the second element gets class “a” too. And if an included template changes an Attribute object you passed in, the change shows up back in the parent. When you need a different set for a second element, make a copy with attributes|without() (which clones it) or start fresh with create_attribute().
Drupal specific twig templates
Where templates come from
Every piece of HTML Drupal outputs comes from a template, provided by core, a module or a theme. To change one, copy it into your theme’s templates folder (any subfolder works), change it, and clear the cache. Drupal picks the most specific template it can find, in this order: your theme, its base themes, then the module that defined it. The fastest way to find out which template made a piece of HTML is Twig debug mode.
Start from the template in your base theme, or from core’s Stable 9 theme if you don’t have one (core/themes/stable9/templates), rather than the module’s own version, so your markup doesn’t change when the module is updated.
Template naming conventions
Template file names are built from the theme hook plus more specific parts, separated by double hyphens. The most specific match wins. Underscores in machine names become hyphens in file names: a content type called case_study gives node--case-study.html.twig.
- HTML:
html--node--12.html.twig,html--node.html.twig,html.html.twig - Page:
page--node--edit.html.twig,page--node--12.html.twig,page--node.html.twig,page--front.html.twig,page.html.twig - Region:
region--sidebar-first.html.twig,region.html.twig - Block:
block--[module]--[delta].html.twig,block--[module].html.twig,block.html.twig. For example,block--system-branding-block.html.twig, or for a Views block,block--views-block--front-news-block-1.html.twig - Node:
node--12--teaser.html.twig,node--12.html.twig,node--article--teaser.html.twig,node--article.html.twig,node--teaser.html.twig,node.html.twig - Taxonomy term:
taxonomy-term--12.html.twig,taxonomy-term--tags.html.twig,taxonomy-term.html.twig - Field:
field--node--field-image--article.html.twig,field--node--field-image.html.twig,field--node--article.html.twig,field--field-image.html.twig,field--image.html.twig(the field type),field.html.twig - Comment:
comment--field-comments--article.html.twig,comment.html.twig - Views:
views-view--[view]--[display].html.twig,views-view--[view]--[display-type].html.twig,views-view--[display-type].html.twig,views-view--[view].html.twig,views-view.html.twig. Row and field templates follow the same pattern:views-view-unformatted--[view].html.twig,views-view-fields--[view].html.twig,views-view-field--[view]--[field].html.twig - Maintenance page:
maintenance-page.html.twig
A page template for one content type, such as page--article.html.twig, doesn’t exist out of the box. You add the suggestion yourself (see the next section).
Adding your own template suggestions
To make Drupal look for a template it doesn’t suggest by default, add a suggestion in your theme’s .theme file:
/**
* Implements hook_theme_suggestions_HOOK_alter() for page templates.
*/
function mytheme_theme_suggestions_page_alter(array &$suggestions, array $variables) {
if ($node = \Drupal::routeMatch()->getParameter('node')) {
$suggestions[] = 'page__' . $node->bundle();
}
}
Suggestions use double underscores and no hyphens (page__case_study); Drupal turns them into page--case-study.html.twig. If you get that wrong, Twig debug lists your suggestion under “INVALID FILE NAME SUGGESTIONS”, which is a great clue. From inside a template, the add_suggestion filter does the same job for a single render array.
Including templates from other themes and modules
Drupal registers a Twig namespace for every enabled theme and module. @mytheme/ points at the theme’s templates folder first, then the theme’s root folder, so both of these work:
{% include '@mytheme/partials/card.html.twig' %} {# themes/custom/mytheme/templates/partials/card.html.twig #}
{{ source('@mytheme/images/logo.svg') }} {# themes/custom/mytheme/images/logo.svg #}
Twig in Drupal only loads files with certain extensions: css, html, js, svg and twig. That’s why source() can inline an SVG but not a PNG. You can change the list with twig.config: allowed_file_extensions in your services.yml.
Including a file directly by path skips Drupal’s theme system: no preprocess functions, no template suggestions and no Twig debug comments. For anything other than small partials, render a proper theme hook or a single directory component instead.
Working with entities and fields
content vs node
In an entity template such as node.html.twig, you get two very different things:
content: render arrays for each field, built with the formatters and settings from “Manage display”. Printing{{ content.field_image }}gives you the image formatted, with the right image style, field template, access checks and cache metadata.node(orterm,user,media,paragraph…): the entity object itself, with its raw values.{{ node.field_image.alt }}gives you a single raw value, with no formatter, no template and no cache metadata.
Use content wherever you can. It keeps site builders in control through “Manage display”, it’s translated and access-checked, and its cache metadata keeps your pages up to date. Reach for raw values when you need something no formatter gives you, such as a value for a class name or a data attribute.
Printing raw field values
{{ node.label }} {# the title #}
{{ node.id }} {# the node ID #}
{{ node.bundle }} {# the content type's machine name #}
{{ node.field_subtitle.value }} {# a text or number field #}
{{ node.body.value }} {# body text, unfiltered! #}
{{ node.body.summary }} {# the body's summary #}
{{ node.field_link.uri }} {# a link field's URI #}
{{ node.field_link.title }} {# a link field's text #}
{{ node.field_image.alt }} {# an image's alt text #}
{{ node.field_image.entity.uri.value }} {# an image's file URI #}
{{ node.field_tags.entity.label }} {# the first referenced term's name #}
{{ node.field_tags.target_id }} {# the first referenced term's ID #}
{{ node.field_featured.value ? 'Featured' }} {# a boolean field #}
{{ node.created.value }} {# a timestamp #}
node.field_x.value, node.field_x[0].value and node.field_x.0.value all give the first item’s value.
Be careful with text fields: node.body.value is the raw text as stored, before Drupal’s text formats have filtered it. Twig will escape it, so you’ll see HTML tags printed as text; and if you add |raw to “fix” that, you’ve bypassed the filtering that keeps editors’ HTML safe. Print content.body instead.
Is this field empty?
This is the question I get asked most. {% if content.field_image %} is true even when there’s no image, because Drupal still returns a render array carrying cache metadata. There are two good ways to check:
{# 1. Check the raw field. Fast, but ignores field access and display settings. #}
{% if not node.field_image.isEmpty() %}
<div class="card__media">{{ content.field_image }}</div>
{% endif %}
{# 2. Render once, then check the output. Respects everything, costs a render. #}
{% set media = content.field_image|render %}
{% if media|trim is not empty %}
<div class="card__media">{{ media }}</div>
{% endif %}
Don’t add |striptags to the second version for image or media fields: it strips the <img> and every check comes back empty. The same trick works for regions in page.html.twig: a region that has blocks which render nothing still counts as “not empty”, so render it first and check the result.
Looping over field items
Inside a field template (field--*.html.twig), loop over items and print each item.content; item.attributes holds the item’s attributes:
<ul{{ attributes.addClass('tag-list') }}>
{% for item in items %}
<li{{ item.attributes }}>{{ item.content }}</li>
{% endfor %}
</ul>
Inside an entity template, content.field_tags mixes numbered items with # properties, so filter out the properties before you loop. Twig Tweak’s children filter does the same in fewer characters:
{% for key, tag in content.field_tags|filter((v, k) => k|first != '#') %}
<li>{{ tag }}</li>
{% endfor %}
Or loop over the raw values: {% for tag in node.field_tags %}{{ tag.entity.label }}{% endfor %}.
Entity references, access and translation
node.field_author.entity loads the referenced entity, so you can chain into its fields: {{ node.field_author.entity.field_job_title.value }}. Three things to know before you do:
- No access checks. You’ll print a referenced entity even if it’s unpublished or the visitor isn’t allowed to see it.
- No translation. On a multilingual site,
.entitygives you the referenced entity in its original language, not the language of the page. Twig Tweak’s translation filter fixes that. - No cache metadata. If the referenced entity changes, your cached page doesn’t know. More on that under cache metadata.
If you need a referenced entity’s fields, set the field’s formatter to “Rendered entity” with a view mode instead, and theme that view mode’s template. It handles all three.
Links and URLs
Entity templates give you url, the entity’s URL, so a teaser link is just <a href="{{ url }}">{{ label }}</a>. For other links, use path and url, or link for a ready-made link.
Don’t try {{ node.toUrl() }}. Drupal’s sandbox blocks it (see the next section) and you’ll get an error. The url variable, path('entity.node.canonical', { node: node.id }) or Twig Tweak’s entity_url all work.
Images
The best way to print an image is through its formatter: {{ content.field_image }}, with the image style or responsive image style set in “Manage display”. That way you get srcset, lazy loading, width and height attributes and cache metadata for free.
When you need just the URL (for a background image, say), Drupal core has file_url() but no way to apply an image style in Twig. Either add the URL in a preprocess function, or use Twig Tweak’s image_style filter:
{{ file_url(node.field_image.entity.uri.value) }}
{{ node.field_image.entity.uri.value|image_style('large') }} {# needs Twig Tweak #}
For a media field, it’s one level deeper: node.field_media.entity.field_media_image.entity.uri.value. Yes, really.
Dates
For timestamps (the created and changed fields), use format_date. Date fields are trickier: the stored value is a string in UTC, such as 2026-05-11T09:00:00, and Twig’s date filter would read it in the wrong timezone. Convert it to a timestamp first, and let format_date handle the visitor’s timezone and language:
{{ node.field_event_date.date.getTimestamp()|format_date('custom', 'j F Y, g:ia') }}
{{ content.field_event_date }} {# or just use the formatter #}
Translating text
Wrap every piece of interface text you write in a template in t or trans, even on a single-language site. It costs nothing, and it means the text can be changed through the interface translation pages without a deploy:
<button class="search-toggle">{{ 'Search'|t }}</button>
{% trans %}Posted {{ date }}{% endtrans %}
Cache metadata, and why your change isn’t showing
Drupal caches rendered output and uses cache metadata (tags, contexts and max-age) to know when a cached copy is out of date. Render arrays carry that metadata, and printing them passes it up the page. Raw values don’t carry any. So when you print node.field_author.entity.label, Drupal doesn’t know your output depends on that author, and when someone edits the author, the old name stays in the cache.
Two fixes:
{# 1. Print the render arrays you used, or at least render them, so their metadata comes along. #}
{% set catch_cache = content|render %}
{# 2. With Twig Tweak, attach just the metadata of what you used. #}
{{ content.field_author|cache_metadata }}
The first is common in contributed themes, but it renders all of content, which isn’t free. Better still, prepare what you need in a preprocess function and add the right cache tags in PHP.
The same goes for logged_in, is_admin and user from the default variables: they don’t add any cache contexts of their own. Drupal varies its caches by the visitor’s permissions by default, which covers most cases, but if your template shows something different per user, add a user cache context in a preprocess function.
The Twig sandbox in Drupal
Drupal runs every template in Twig’s sandbox, with a policy that limits which methods you can call on objects. This stops a template from doing something destructive like {{ node.delete() }}. You can call:
- Any method on Attribute objects.
id(),label(),bundle(),get(),__toString()andtoString()on any object.- Any method whose name starts with
get,hasoris, such asnode.getTitle(),node.hasField('field_tags')andnode.isPublished().
Anything else, such as node.toUrl(), node.access('update') or date.format('Y'), fails with an error like this:
Twig\Sandbox\SecurityError: Calling "toUrl" method on a "Drupal\node\Entity\Node" object is not allowed.
You can widen the policy in settings.php with $settings['twig_sandbox_allowed_methods'], twig_sandbox_allowed_prefixes and twig_sandbox_allowed_classes, but please don’t: work out the value in a preprocess function and pass it to the template instead.
Single directory components (SDC)
Single directory components keep a component’s Twig, YAML schema, CSS and JavaScript together in one folder, such as themes/custom/mytheme/components/card/. They became part of Drupal core in 10.1 and are stable from Drupal 10.3. Drupal attaches the component’s CSS and JavaScript for you whenever it’s used.
You call a component by its ID, [theme or module]:[component], with include for props or embed to fill its slots (slots are Twig blocks in the component’s template):
{# Props only #}
{{ include('mytheme:card', {
heading: label,
url: url,
variant: 'featured',
}, with_context = false) }}
{# Props and slots #}
{% embed 'mytheme:card' with { heading: label, url: url } only %}
{% block media %}
{{ content.field_image }}
{% endblock %}
{% block body %}
{{ content.body }}
{% endblock %}
{% endembed %}
Pass components only what they need (only, or with_context = false), so they behave the same wherever you use them. Drupal checks props against the component’s schema, so a typo in a prop name or the wrong type shows up as an error while you develop rather than a blank space on the page. You’ll also see add_component_context() and validate_component_props() in compiled templates; Drupal uses them internally, and you don’t need to call them.
For how SDC compares with Storybook, see Pri’s Drupal SDC vs Storybook showdown.
Debugging Twig in Drupal
Turn on Twig debug mode
From Drupal 10.1, go to Configuration › Development › Development settings (/admin/config/development/settings) and tick “Twig development mode”, then “Twig debug mode” and “Disable Twig cache”. “Do not cache markup” is useful too, while you’re working on templates.
On older versions, or to keep it in code, add this to sites/development.services.yml and include that file from settings.local.php:
parameters:
twig.config:
debug: true
auto_reload: true
cache: false
Never switch debug mode on for a live site. The extra comments can break some output, such as views that produce JSON or plain text, and your tests may fail with it on.
Reading the debug comments
With debug on, view the page source (not the browser’s inspector, which can hide comments) and every template is wrapped in comments like these:
<!-- THEME DEBUG -->
<!-- THEME HOOK: 'node' -->
<!-- FILE NAME SUGGESTIONS:
▪️ node--12--full.html.twig
▪️ node--12.html.twig
▪️ node--article--full.html.twig
✅ node--article.html.twig
▪️ node--full.html.twig
▪️ node.html.twig
-->
<!-- 💡 BEGIN CUSTOM TEMPLATE OUTPUT from 'themes/custom/mytheme/templates/content/node--article.html.twig' -->
...
<!-- END CUSTOM TEMPLATE OUTPUT from 'themes/custom/mytheme/templates/content/node--article.html.twig' -->
The tick shows the template in use, the list shows every file name you could create to override it (most specific first), and the 💡 tells you it came from your theme rather than core or a module.
Dumping variables
{{ dump() }} dumps every variable in the template and {{ dump(label) }} dumps one. When Symfony’s VarDumper component is installed, Drupal uses it for collapsible, readable output. Both only work in debug mode.
Don’t dump everything in an entity template. dump() on its own, or dump(node), tries to print the whole entity, its fields, its references and Drupal’s services behind them. It can run out of memory or take minutes. List the variable names first, then dump the one you want:
{{ dump(_context|keys) }}
{# Works even without debug mode on: #}
<pre>{{ _context|keys|join(', ') }}</pre>
The Devel (opens in a new tab) module adds {{ devel_dump() }} and {{ devel_breakpoint() }}, and Twig Tweak has drupal_dump.
Breakpoints with Xdebug
Twig compiles each template into a PHP class, so you can step through it with Xdebug. Twig Tweak’s {{ drupal_breakpoint() }} and Devel’s {{ devel_breakpoint() }} stop at that point in the compiled template, with the template’s variables available to inspect. Drupal’s documentation says to set twig.config: auto_reload: false when you do this. The compiled templates live in sites/default/files/php/twig/.
Clearing the Twig cache
Drupal caches compiled templates. If a new template doesn’t show up, run drush cr. A new template file (rather than a change to an existing one) always needs a cache rebuild, even with “Disable Twig cache” ticked, because Drupal has to rediscover its templates.
Common errors
- Nothing prints: a misspelt variable. Drupal stays silent about undefined variables.
- “Calling “X” method on a “Y” object is not allowed”: the sandbox. Prepare the value in a preprocess function.
- “Object of type X cannot be printed”: you’ve printed an object that can’t become a string, often a field item list or entity. Print a property (
.value,.label) or the render array fromcontent. - “Unknown “cache” tag” or “Unknown “spaceless” tag”: a Symfony or Twig 1 and 2 example. See cache and spaceless.
- “Unable to find template”: for
@mytheme/...paths, check the file is in the theme’stemplatesfolder or root and the theme is enabled. “Unable to find template “x.html.twig” in the Drupal theme registry” means you used a plain name that isn’t a theme hook: add the@mytheme/namespace. - HTML printed as text: you printed a raw value (such as
node.body.value). Print the render array fromcontentinstead, rather than reaching for|raw.
What not to do in Twig
Twig is for markup. The more logic you put in templates, the harder they are to read, test and keep fast. A few habits that will save future you:
- Don’t fetch data in templates. Loading entities, running views or reading config belongs in a preprocess function or a block plugin, where you can add cache metadata and access checks. That includes Twig Tweak’s
drupal_entity()anddrupal_view()(see below). - Don’t print raw values when a formatter will do. Raw values skip formatters, translation, access checks and cache metadata.
- Don’t use
|rawon anything that came from a person. - Don’t render the same thing twice. Store
|renderoutput in a variable. - Don’t remove
title_prefixandtitle_suffix. Contextual links (the pencil icon) need them. - Don’t hard-code text. Use
tortrans. - Don’t build huge templates. If a template is hundreds of lines long, split it into includes or components.
- Do move complex logic into preprocess. A preprocess function in your
.themefile can work out a value once, add cache metadata and pass the template a simple variable to print.
Drupal twig tweak
Twig Tweak (opens in a new tab) is a contributed module that adds a long list of Twig functions and filters, and over 220,000 sites use it. Version 3.4 is the stable release for Drupal 10.3 and 11, and version 4 is in alpha for Drupal 11.4 and later.
composer require drupal/twig_tweak
drush en twig_tweak
A word of warning first
Twig Tweak is brilliant for a quick prototype, a one-off field you need in an odd place, or a site with no developer to write a preprocess function. Use it with your eyes open, though:
- It hides site building in templates. A view or block placed with
drupal_view()ordrupal_block()doesn’t appear in the block layout, so site builders can’t see it, move it or turn it off, and nothing warns you if someone deletes the view. - It can repeat expensive work. Each call loads or builds things during rendering. Inside a loop, that’s once per item:
drupal_entity()in a loop of 50 teasers loads 50 entities.drupal_view_result()followed bydrupal_view()runs the view twice. - Some helpers drop cache metadata.
drupal_config()returns the value only, so if you print the site name with it and someone changes the site name, cached pages keep showing the old one until the cache is cleared. Where a helper returns a render array (drupal_view,drupal_entity,drupal_blockand others), the cache metadata comes with it. - Debug helpers don’t switch off.
dd()anddrupal_dump()dump on any site, debug mode or not, anddrupal_breakpoint()stops Xdebug wherever it’s running. Don’t commit them.
If you find yourself using the same Twig Tweak call on several templates, that’s a sign it belongs in a preprocess function, a block or a view mode.
drupal_view
Embeds a view display: drupal_view(view_name, display_id, ...arguments). The extra arguments go to the view’s contextual filters.
{{ drupal_view('who_s_new', 'block_1') }}
{{ drupal_view('related_content', 'block_1', node.id) }}
drupal_view_result
Returns a view’s result rows (not printable) so you can count or test them:
{% if drupal_view_result('cart')|length == 0 %}
{{ 'Your cart is empty.'|t }}
{% endif %}
Using it to decide whether to print the same view with drupal_view() runs the view twice. Views has a “No results behaviour” setting for that.
drupal_block
Renders a block plugin by its plugin ID, with optional configuration. wrapper=false skips block.html.twig. Blocks rendered this way show their label unless you hide it:
{{ drupal_block('system_branding_block') }}
{{ drupal_block('system_branding_block', { label_display: false, use_site_name: false }) }}
{{ drupal_block('system_branding_block', wrapper=false) }}
{# For blocks that need a context, map it: #}
{{ drupal_block('plugin_id', { context_mapping: { node: '@node.node_route_context:node' } }) }}
To list the block plugin IDs on your site:
drush ev "print_r(array_keys(\Drupal::service('plugin.manager.block')->getDefinitions()));"
drupal_region
Renders all the blocks placed in a region, optionally from another theme: {{ drupal_region('sidebar_first') }}, {{ drupal_region('sidebar_first', 'olivero') }}.
drupal_entity
Renders an entity in a view mode, with access checks on by default: drupal_entity(entity_type, id_or_uuid, view_mode, langcode, check_access).
{{ drupal_entity('block_content', 1) }}
{{ drupal_entity('node', 123, 'teaser') }}
{{ drupal_entity('block', 'olivero_branding', check_access=false) }}
drupal_entity_form
Renders an entity’s add or edit form: drupal_entity_form(entity_type, id, form_mode, values, check_access).
{{ drupal_entity_form('node', 1) }}
{{ drupal_entity_form('node', values={ type: 'article' }) }}
{{ drupal_entity_form('user', NULL, 'register', check_access=false) }}
drupal_field
Renders one field of an entity, using a view mode or your own display settings. It doesn’t work with view modes that use Layout Builder.
{{ drupal_field('field_image', 'node', 1) }}
{{ drupal_field('field_image', 'node', 1, 'teaser') }}
{{ drupal_field('field_image', 'node', 1, { type: 'image_url', settings: { image_style: 'large' } }) }}
{{ drupal_field('field_image', 'node', 1, check_access=false) }}
drupal_menu
Renders a menu, with optional starting level, depth and expansion: drupal_menu(menu_name, level, depth, expand).
{{ drupal_menu('main') }}
{{ drupal_menu('main', 2, 1) }} {# only the second level, one level deep #}
{{ drupal_menu('main', expand=true) }}
drupal_form
Renders a form by its class name: {{ drupal_form('Drupal\\search\\Form\\SearchBlockForm') }}. Extra arguments go to the form’s build method.
drupal_image
Renders an image by file ID, UUID or URI, with an optional image style, attributes and responsive image style:
{{ drupal_image(123) }}
{{ drupal_image('public://ocean.jpg', 'thumbnail', { alt: 'The ocean'|t, loading: 'lazy' }) }}
{{ drupal_image('public://ocean.jpg', 'wide', responsive=true) }}
drupal_token
Replaces a single token, with optional data and options:
{{ drupal_token('site:name') }}
{{ drupal_token('node:title', { node: node }) }}
{{ drupal_token('node:title', { node: node }, { clear: true }) }}
drupal_config
Reads one value from a configuration object: {{ drupal_config('system.site', 'name') }}. See the warning above: it doesn’t carry the config’s cache tag, so cached pages won’t update when the setting changes. For the site name and slogan, the “Site branding” block is the cache-friendly option.
drupal_dump and dd
Dumps a variable, or every variable with no arguments, using Symfony’s VarDumper: {{ dd(node.label) }}. Unlike core’s dump(), it works whether debug mode is on or not. Handy, but easy to leave behind on a live site.
drupal_title
Prints the current page’s title, cached per URL: {{ drupal_title() }}.
drupal_url and drupal_link
Build a URL or a link from a path as an editor would type it (/node/1, node/1, ?page=2, #anchor) or an external URL, instead of a route name. With check_access=true, they return nothing if the visitor can’t access the target:
{{ drupal_url('node/1', { query: { foo: 'bar' }, fragment: 'example', absolute: true }) }}
{{ drupal_link('View'|t, 'node/1', { attributes: { target: '_blank' } }) }}
{{ drupal_link('Administration'|t, '/admin', check_access=true) }}
Use core’s path, url and link when you know the route name; use these when you start from a path.
drupal_messages
Prints the status messages: {{ drupal_messages() }}.
drupal_breadcrumb
Prints the breadcrumb: {{ drupal_breadcrumb() }}.
drupal_breakpoint
Stops Xdebug at this point in the compiled template: {{ drupal_breakpoint() }}.
drupal_contextual_links
Adds contextual links (the pencil icon) to something you’ve printed yourself. The wrapper needs the contextual-region class:
<div class="contextual-region">
{{ drupal_contextual_links('entity.view.edit_form:view=frontpage:display_id=feed_1') }}
{{ drupal_view('frontpage') }}
</div>
token_replace
Replaces every token in a string or block of markup:
{{ '<h1>[site:name]</h1><div>[site:slogan]</div>'|token_replace }}
{% apply token_replace %}
<h1>[site:name]</h1>
<div>[site:slogan]</div>
{% endapply %}
preg_replace
Replaces text with a regular expression: {{ 'Drupal - community plumbing!'|preg_replace('/(Drupal)/', '<b>$1</b>') }}. For simple replacements, Twig’s own replace and format are faster and easier to read.
image_style
Returns the URL of an image in an image style: {{ 'public://images/ocean.jpg'|image_style('thumbnail') }}. Check the URI isn’t empty first, so you don’t print a broken image:
{% set image_uri = node.field_image|file_uri %}
{% if image_uri is not null %}
<div class="hero" style="background-image: url({{ image_uri|image_style('wide') }});"></div>
{% endif %}
transliterate
Converts a string to plain ASCII: {{ 'Привет!'|transliterate }}. Handy for class names and file names.
check_markup
Filters a string through a text format: {{ node.field_notes.value|check_markup('basic_html') }}. This is the safe way to print a raw text field’s HTML if you really can’t use content.
format_size
Turns a number of bytes into a readable size: {{ node.field_file.entity.filesize.value|format_size }} gives something like “1.2 MB”.
truncate
Shortens a string to a number of characters, optionally at a word boundary and with an ellipsis: truncate(max_length, wordsafe, add_ellipsis).
{{ node.body.summary|truncate(120, true, true) }}
view
Renders an entity or a field in a view mode, or a field with display settings. Don’t use it on the entity a template is already rendering (such as node|view inside node.html.twig): it renders itself forever.
{{ node.field_related.entity|view('teaser') }}
{{ node.field_image|view({ settings: { image_style: 'thumbnail' } }) }}
with
The opposite of core’s without: sets a property or child on a render array.
{{ content.field_image|with('#title', 'Photo'|t) }}
{{ content|with(['field_image', '#title'], 'Photo'|t) }}
data_uri
Turns content into a data URI. Twig’s HTML extension (in Drupal core from 11.4) has a filter with the same name and a similar job, so check the output if it surprises you.
<img src="{{ source('@mytheme/images/logo.svg')|data_uri('image/svg+xml') }}" alt="{{ 'Logo'|t }}">
children
Returns a render array’s children without its # properties, optionally sorted by weight. The easiest way to loop over a field’s items in an entity template:
<ul>
{% for tag in content.field_tags|children %}
<li>{{ tag }}</li>
{% endfor %}
</ul>
file_uri and file_url
Get a file’s URI or URL from a field, a field item or a file or media entity. They work with media fields too, including remote video (oEmbed), and file_url(false) gives an absolute URL:
{{ node.field_image|file_uri }}
{{ node.field_image[1]|file_url }}
{{ node.field_media|file_url }}
{{ node.field_document|file_url(false) }}
Core’s file_url() is a function that only takes a URI string; Twig Tweak’s file_url is a filter that also takes fields and entities. Same name, different jobs.
entity_url
Returns an entity’s URL object, for the canonical page or another link such as the edit form: {{ node|entity_url }}, {{ node|entity_url('edit-form') }}.
entity_link
Returns a link to an entity, using its label or your own text: {{ node|entity_link }}, {{ node|entity_link('Add new comment'|t, 'canonical', { fragment: 'comment-form' }) }}.
translation
Returns the entity in the current language (or a language you name), which you’ll need when printing data from a referenced entity on a multilingual site: {{ node.field_media.entity|translation.name.value }}.
cache_metadata
Extracts just the cache metadata from a render array or object and passes it up the page, so you can print raw values without stale caches:
<img src="{{ node.field_media|file_url }}" alt="">
{{ content.field_media|cache_metadata }}
php
Runs PHP code. It’s disabled unless you add $settings['twig_tweak_enable_php_filter'] = TRUE; to settings.php, and you should leave it disabled: PHP in templates is a security risk, and there’s always a better way.
New in Twig Tweak 4
The 4.0 alpha adds drupal_logger() to write to Drupal’s log from a template, {{ drupal_logger('mytheme', 'notice', 'Missing expected field') }}, and a token_replace_plain filter that keeps replaced values as plain text, for use inside HTML attributes and meta tags. Never combine token_replace_plain with |raw.
Why I wrote this
I’ve been writing Twig templates since early 2015, so more than eleven years now. In that time I’ve lost count of the number of times I’ve had the Twig docs, a handful of drupal.org pages and an old Stack Overflow answer open in different tabs, trying to remember whether something is a Twig thing, a Drupal thing or a Twig Tweak thing, and whether it still works in the version I’m using. I wanted one page that answers all of those questions in one go, for me as much as anyone else!
A lot of what’s in here I learned the hard way: fields that are never empty, attributes that change in two places at once and caches that refuse to update. Writing it all down felt like the least I could do for whoever runs into those same walls next.
I hope it helps you, and saves you some of the head-scratching it cost me. Spotted something out of date, or got a favourite Twig trick I’ve missed? Let me know and I’ll add it. And if your Drupal theme is fighting you more than it should, we can help with that too!
Further reading
- Twig documentation (3.x) (opens in a new tab), including the full lists of tags (opens in a new tab), filters (opens in a new tab), functions (opens in a new tab) and tests (opens in a new tab). Some of them come from extra packages that Drupal doesn’t include, so check the “installation” note at the top of a page.
- Twig in Drupal (opens in a new tab) on drupal.org, especially Working with Twig templates (opens in a new tab), Twig template naming conventions (opens in a new tab), Filters (opens in a new tab), Functions (opens in a new tab) and Debugging Twig templates (opens in a new tab).
- Using single directory components (opens in a new tab) on drupal.org.
- The Twig Tweak cheat sheet (opens in a new tab).


