Every lesson so far has been about PHP: building forms, registering routes, defining fields, reacting to events. But none of that PHP code has actually written a single line of HTML. That's not an accident — it's one of Drupal's core design principles, and this lesson is where you finally meet the tool that turns data into markup: Twig.
We're going to open a real template file from the theming_example module and read exactly how a PHP render array becomes HTML on the page — including a genuine, documented bug in the official example code that turns out to be a great teaching moment.
Why doesn't PHP just print HTML directly?
You could, technically, write return '<p>' . $text . '</p>'; in a PHP controller. Drupal deliberately avoids this. Instead, module PHP code builds a render array — a plain associative array describing what should appear — and hands it off to Drupal's theme system, which uses a Twig template to decide how it should look as HTML.
Why bother with the extra layer? Because it means a site builder can completely restyle a module's output — copy one template file into their theme, edit the markup, done — without touching a single line of the module's PHP. The module never has to be forked or hacked. This separation is the single most important idea in Drupal theming, and everything else in this lesson exists to support it.
What you'll learn in this lesson
- How a module registers a theme hook with
hook_theme()so Drupal knows a template exists - How Drupal automatically maps a hook name to a template filename
- The core Twig syntax you'll see constantly: comments, loops, the
loopobject, and output tags - Why Twig auto-escapes your output by default, and when you'd deliberately turn that off
- How to spot — and fix — a real subtle bug in template logic
theming_example module, another module in the official Drupal Examples project. Same course, same DDEV site, same rule as always: every code sample and screenshot here comes from a real file and a real page load, nothing hypothetical.The source file
Path: modules/theming_example/templates/theming-example-content-array.html.twig
{#
/**
* @file
* Theme a simple content array.
*
* This template uses the newer recommended format where a single
* render array is provided to the theme function.
*/
#}
{% for item in element['#items'] %}
{% if not loop.index %}
{# The first paragraph is bolded. #}
<p><strong>{{ item }}</strong></p>
{% else %}
{# Following paragraphs are just output as routine paragraphs. #}
<p>{{ item }}</p>
{% endif %}
{% endfor %}
Sixteen lines, and there's genuinely a lot packed into them. Let's go through it piece by piece.
How it works, piece by piece
The Twig comment block
{#
/** ... */
#}
Twig comments are wrapped in {# and #}. Everything inside is stripped out entirely before the page ever reaches a browser — it costs nothing at runtime and never leaks into your HTML source. The /** @file ... */ docblock inside follows Drupal's coding standards, describing what the file does for the next developer who opens it. You'll see this exact pattern at the top of almost every Drupal template.
The for loop and where element comes from
{% for item in element['#items'] %}
Twig uses {% ... %} delimiters for logic (loops, conditionals) as opposed to {{ ... }} for output. Here, element is the entire render array that got handed to this template, and element['#items'] reaches into a specific property on it — a list of text strings the PHP controller assembled. That element variable name isn't arbitrary: it comes directly from how this template's theme hook was registered (more on that below).
The loop object — and a real bug worth understanding
{% if not loop.index %}
Every Twig for loop automatically exposes a special loop object with useful properties: loop.index (1-based position), loop.first, loop.last, and loop.length. The inline comment right above this line says "The first paragraph is bolded" — so the intent is clearly to special-case the very first item.
loop.index starts counting at 1, not 0. In Twig, any positive number is "truthy," so not loop.index is not true — which is always false, on every single iteration, including the first. The {% if %} branch this comment refers to never actually runs. Every paragraph — including the first — falls through to the plain <p>{{ item }}</p> branch. This is a real, harmless-but-genuine bug sitting in the official Drupal Examples code. The fix would be {% if loop.first %}, which does exactly what the comment describes. We're pointing it out on purpose: reading real code means encountering real imperfections, and recognizing this pattern will save you from writing the same off-by-one mistake yourself.Output tags and auto-escaping
{{ item }}
Double braces print a variable's value. By default, Drupal's Twig configuration auto-escapes every output — if item contained <script>, Twig would print it as harmless text, not execute it. This is a built-in defense against cross-site scripting (XSS). The one time you'd deliberately bypass this is when you're printing something you already know is safe, pre-rendered HTML — like another render array — using the |raw filter or Drupal's render filter instead of plain {{ }} output.
Markup stays in the template, never in PHP
Notice that the <p> and <strong> tags live entirely inside this .html.twig file — nowhere in the module's PHP is there a string containing HTML. This is the practical payoff of the separation described earlier: a theme developer can copy this exact file into their theme's own templates/ folder, change the markup freely, and Drupal will use their version instead — with zero changes to the module.
Connecting the template back to PHP: hook_theme()
A template file sitting in a templates/ folder does nothing on its own. Drupal only knows to use it because the module registers it in hook_theme(), inside theming_example.module:
'theming_example_content_array' => [
'render element' => 'element',
],
This is the missing link: it tells Drupal "there is a theme hook called theming_example_content_array, and whatever gets passed to it should be exposed to the template under the variable name element." That's exactly the element variable the template used above. Drupal then automatically derives the template filename by replacing underscores with hyphens and appending .html.twig — theming_example_content_array becomes theming-example-content-array.html.twig, found automatically inside the module's templates/ directory. You'll dig much deeper into hook_theme() itself in the next lesson.
The other two templates, briefly
The same templates/ folder ships two more files worth knowing about, since you'll see them referenced in the next lesson:
theming-example-list.html.twig— registered with'variables' => ['title' => NULL, 'items' => NULL]instead ofrender element. It receives plain named variables directly rather than a self-describing render array.theming-example-text-form.html.twig— registered with an explicit'template'key that overrides Drupal's automatic filename-derivation convention.
See it for yourself
Visit the theming example overview page and follow the "Simple page with a list" link. You'll see two versions of the exact same underlying data, rendered two completely different ways:
The first list — "First item," "Second item," "Third item," "Fourth item" — is rendered by core's built-in theme('item_list'), a plain bulleted list. Directly below it, the exact same four strings are rendered again, this time by theming_example's own custom theme hook, as a lettered A/B/C/D ordered list. Same data, two completely different templates controlling the output — that contrast is the whole lesson made visible.
Quick check: if you wanted the first item in a Twig
forloop to render differently from the rest, which condition should you actually write —{% if not loop.index %}or{% if loop.first %}? If you saidloop.first, you just avoided the exact bug we found in the official example code.
Key takeaways
- Register every custom theme hook in
hook_theme(); Drupal automatically maps a hook namedmy_module_footo a template file calledmy-module-foo.html.twigin the module'stemplates/directory, unless you override it with atemplatekey. - Use
'render element'when your template receives a self-describing render array (accessed aselement['#property']in Twig); use'variables'when you want plain, explicitly-declared named values instead. - Twig comment blocks (
{# ... #}) are stripped before rendering and never reach the browser — use them freely for documentation with zero performance cost. - The
loopobject inside aforloop gives youloop.index(1-based),loop.first,loop.last, andloop.length— preferloop.firstovernot loop.indexwhen special-casing the first iteration, sinceloop.indexis never falsy. - Twig auto-escapes output by default, protecting against XSS; only use the
|rawfilter when you're certain the value is already safe, pre-rendered HTML. - Overriding a module's template is as simple as copying it into your theme's
templates/folder and editing the markup — no module code changes, no forking, no hacking core.
Coming up next
You've now seen the simplest possible theme hook — one template, one render array. But real Drupal sites often need a template to look different depending on context: a different template for a specific content type, a specific view mode, or a specific page. In the next lesson, we'll look at template suggestions and preprocess functions — the mechanism that lets a theme override output at increasing levels of specificity, and the clean place to transform data right before it reaches Twig.