You've now traced a request through every layer of Drupal's plumbing: a route decides what URL maps to what code, a permission decides who's allowed to trigger it, a controller runs the actual logic and hands back a render array. There's one piece left, and it's the one that finally puts pixels — well, HTML — in front of a real visitor: Twig, Drupal's templating engine.
Why Drupal uses a templating engine at all
You already saw one way to produce HTML from a controller: #markup with a hand-built string. That works fine for a single sentence, but it falls apart fast for anything more structured — mixing PHP string concatenation with HTML gets ugly and hard to theme. Twig solves this by giving you a dedicated, HTML-like syntax for templates, kept in their own .html.twig files, completely separate from your PHP logic. A themer who's never written a line of PHP can open a Twig file and understand — and safely modify — exactly what gets rendered.
What you'll learn in this lesson
- How Twig comments differ from HTML comments
- How to generate a URL from a route name, right inside a template, using
path() - How to make a whole block of HTML translatable with
{% trans %} - How Twig automatically protects you from cross-site-scripting when printing variables
The source file
Path: modules/page_example/templates/description.html.twig
{#
/**
* @file
* Contains the text of the page_example explanation page
*/
#}
{% set page_example_simple = path('page_example.simple') %}
{% set page_example_arguments = path('page_example.arguments', {'first': 23, 'second': 56}) %}
{% trans %}
<p>The Page example module provides two pages, "simple" and "arguments".</p>
<p>The <a href={{ page_example_simple }}>simple page</a> just returns a renderable array for display.</p>
<p>The <a href={{ page_example_arguments }}>arguments page</a> takes two arguments and displays them, as in {{ page_example_arguments }}</p>
{% endtrans %}
Nine meaningful lines, and every mechanic you need for your first real Twig template is already in there.
How it works
Twig comments vs. HTML comments
{#
/**
* @file
* Contains the text of the page_example explanation page
*/
#}
In Twig, anything between {# and #} is a comment — and unlike an HTML comment (<!-- ... -->), which still gets sent to the browser and is visible if someone views page source, a Twig comment is stripped out entirely before the page is ever rendered. The visitor never sees it, not even in the raw HTML. Inside this particular comment, the @file docblock follows Drupal's coding standards convention for documenting what a file is for — a habit worth picking up early, since it's exactly the kind of thing that makes a codebase navigable six months later.
Generating a URL from a route name with path()
{% set page_example_simple = path('page_example.simple') %}
{% set page_example_arguments = path('page_example.arguments', {'first': 23, 'second': 56}) %}
The {% set %} tag declares a Twig variable — the templating equivalent of a PHP $variable = ...; assignment. Both lines here assign the result of Drupal's path() Twig function, which should feel familiar: it's the exact same idea as the menu link's route_name key from the last lesson, just callable directly from a template.
path('page_example.simple')looks up that route by its machine name and returns its current URL — something like/examples/page-example/simple.path('page_example.arguments', {'first': 23, 'second': 56})does the same thing, but this route needs the two path parameters you met in the controller lesson. The second argument topath()is a Twig map (a set of key/value pairs) supplying values for those{first}and{second}placeholders, producing something like/examples/page-example/arguments/23/56.
Generating URLs this way — by route name, not by typing out the path string — means these links keep working even if the underlying URL structure ever changes. It's the same principle you've now seen in routing, menu links, and here in templates: reference things by their stable machine name, not by a URL that might move.
Making a whole block translatable with {% trans %}
{% trans %}
...
{% endtrans %}
This is Twig's equivalent of the PHP $this->t() you used in the controller lesson. Everything between {% trans %} and {% endtrans %} is registered as one translatable unit, which Drupal's locale system can extract and hand to a translator. Whatever HTML markup sits inside the block — the <p> and <a> tags here — is preserved as part of the translatable string, so a translator working in another language can reorder words, move links around, and still produce grammatically correct sentences in their own language, rather than being handed disconnected fragments to translate blind.
Printing a variable with {{ }}
<a href={{ page_example_simple }}>simple page</a>
...
as in {{ page_example_arguments }}
Double curly braces are how you print a variable's value into the output. Notice page_example_arguments is used twice in this template — once as the destination of a link, and again later, printed as plain visible text so the reader can see the literal URL. That's a deliberate illustration that once you've assigned a Twig variable with {% set %}, you can reuse it anywhere in the template without recalculating it.
{{ }} to guard against cross-site-scripting, unless that value has already been explicitly marked safe. URLs coming out of path() are trusted by Drupal, so they render cleanly as an href attribute without you needing to do anything extra — but if you were ever printing raw user input this same way, that automatic escaping is exactly what keeps a malicious value from breaking out of the HTML and injecting a script.See it for yourself
Visit /examples/page-example on your own DDEV site. The description text at the top of the page — the two paragraphs explaining the "simple" and "arguments" pages, with working links to each — is entirely produced by this one Twig template.
Quick check: why does the template use
path('page_example.arguments', {'first': 23, 'second': 56})instead of just writing the literal string/examples/page-example/arguments/23/56? (Answer: because if the route's URL pattern ever changed — say, to/examples/page-example/args/{first}/{second}— a hardcoded link would silently 404, while apath()-generated one would keep working automatically.)
Key takeaways
- Twig comments use
{# ... #}and are stripped entirely before rendering — unlike HTML comments, they never reach the browser. Include a@filedocblock at the top of every template, matching Drupal coding standards. path('route.name')generates a URL from a route's machine name, keeping links resilient to future URL changes — the templating-layer equivalent of everything you learned about routing.- Pass a second argument to
path()— a map like{'param': value}— to fill in dynamic path parameters for routes that need them. - Use
{% set variable = ... %}to avoid repeating an expensive or verbose expression, and reuse that variable as many times as you need in the template. - Wrap translatable HTML in
{% trans %} ... {% endtrans %}so Drupal's locale system can extract and translate it as one coherent unit, HTML tags included. {{ variable }}prints a value to the page, and Drupal auto-escapes it against XSS unless the value is already marked safe — this is true whether you're outputting into an attribute or into visible text.
Coming up next
That closes out Module Basics — you've now walked the entire chain a Drupal request takes, from .info.yml declaring your module exists, through routing, permissions, and a controller, all the way to a Twig template producing the final HTML. Next, we move into one of Drupal's most powerful and most-used systems: hooks. You'll meet hook_help(), a live view-counter built with hook_ENTITY_TYPE_view(), and the three-part pattern — name, implementation, definition — that every Drupal hook follows.