Drupal: Theming Overview and the Render Pipeline

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

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

Every page that Drupal sends to a browser is the end of a chain of small, well-defined steps. A controller returns a render array, the rendering system walks that array, each element that names a theme hook is turned into variables by preprocess functions, a Twig template turns those variables into markup, and the result is wrapped in the regions, the page template and the html template of the active theme. Understanding that chain is what separates a themer who fights the system from one who changes the right layer. This page follows one request from the route to the final bytes, and explains at each step what the theme controls, what a module controls, which screens, files, hooks and classes are involved, how caching information travels with the markup, and which advice found in older tutorials no longer applies.

It was written against Drupal 11.4.x, with Drush 13 or 14 and PHP 8.3 or later, and the facts were checked against the official documentation, the change records and the Drupal core source on 2026-10-30. Drupal 12.0.0 is scheduled for the week of 2026-12-07 and the end of life of Drupal 10 for 2026-12-09, so both are described as upcoming. The running example is the Meridian Museum: the module meridian_collection, which provides an "Opening hours" page, and the theme meridian, which was generated with Starterkit.

Who works with this layer

  • Site builders choose the default and the administration theme on the Appearance page, place blocks in regions and choose view modes. They rarely touch templates.

  • Themers own the theme: its *.info.yml file, regions, libraries, templates, preprocess code and components. They decide how the data that modules provide is presented.

  • Module developers return render arrays, declare theme hooks and ship default templates. A good module never emits finished HTML strings, because that would remove the themer’s ability to change the markup.

  • Administrators and security reviewers care about two properties of the pipeline: output is escaped by default (Twig autoescaping and the filtering of #markup), and cacheability metadata decides what may be shared between visitors.

What a theme controls

A theme is the set of files that controls presentation. Only the *.info.yml file is required; everything else is optional. The folder structure documented on drupal.org lists the usual files:

File or folder Purpose

meridian.info.yml

Metadata, the base theme, the regions, the libraries loaded on every page and the removal or replacement of module CSS.

meridian.libraries.yml

The CSS and JavaScript libraries that the theme defines. Files in css/ and js/ load only if a library lists them. See Libraries and assets.

meridian.breakpoints.yml

Breakpoints for responsive layouts and responsive images. See Breakpoints and responsive images.

meridian.theme

Procedural PHP for preprocess and alter hooks, and for extending the theme settings form.

src/Hook/

Object-oriented hook classes. Themes support them from Drupal 11.3 (see below).

templates/

Twig templates. A file named after a template of a module or of a base theme overrides it.

components/

Single Directory Components. See Single Directory Components.

config/install/, config/schema/

Default theme settings and their schema. See Theme settings and configurable themes.

logo.svg, screenshot.png

The default logo and the thumbnail on the Appearance page.

What the theme does not control matters as much. The theme does not decide which data exists (the content model does), which blocks are placed (that is block configuration, see Blocks and block layout), which fields appear in a view mode (display configuration, see Display modes and image styles) or who may see what (access control). The theme decides how those things look: the markup around them, the classes, the CSS and JavaScript, and the regions into which blocks can be placed.

A minimal info file

# themes/custom/meridian/meridian.info.yml
name: Meridian
type: theme
description: Theme of the Meridian Museum site, generated with Starterkit.
package: Meridian Museum
core_version_requirement: ^11.3
base theme: false
libraries:
  - meridian/global
regions:
  header: Header
  primary_menu: Primary menu
  secondary_menu: Secondary menu
  highlighted: Highlighted
  help: Help
  content: Content
  sidebar_first: Sidebar first
  footer: Footer
  breadcrumb: Breadcrumb

The key base theme has no underscore. base theme: false declares a standalone theme with no parent: nothing is inherited, so every region and template the theme needs is its own. Regions, the page template and the way regions are filled are explained in detail in Theme info files and regions.

Front-end theme and administration theme

Drupal keeps two active themes apart. The default theme renders the public site. The administration theme renders the back office. Both are chosen on the Appearance page (/admin/appearance): a theme is first installed, then set as default, and the "Administration theme" selector assigns the administration theme. A checkbox labelled "Use the administration theme when editing or creating content" extends the administration theme to the node add and edit forms. The choices are stored in configuration: the system.theme object holds the keys default and admin, and the node setting lives in node.settings.

Internally the choice is made by theme negotiation. Services tagged theme_negotiator are asked in priority order whether they apply to the current request, and the first one that does decides the active theme. The negotiator for the administration theme applies to routes that carry the route option _admin_route: TRUE; the permission "View the administration theme" decides whether the visitor receives it. A route declared by a module opts in like this:

# modules/custom/meridian_collection/meridian_collection.routing.yml
meridian_collection.settings:
  path: '/admin/config/content/meridian-collection'
  defaults:
    _form: '\Drupal\meridian_collection\Form\CollectionSettingsForm'
    _title: 'Meridian collection settings'
  requirements:
    _permission: 'administer meridian collection settings'
  options:
    _admin_route: TRUE

Two consequences follow for themers. First, the administration theme is a separate piece of software with its own templates, so an override placed in meridian does not change the node edit form unless the checkbox above sends the form through meridian. Second, the administration theme can differ by Drupal version: Claro is the administration theme of Drupal 11, while Drupal 12 removes Claro, Olivero and Stable 9 from core and uses Default Admin, based on Gin, as its administration theme. Sites that depend on them install the contributed copies (for example drupal/olivero:^2.0, which supports 11.4 and 12). The administration experience is described in Admin UI, navigation and administration themes, and the upgrade consequences in Preparing for Drupal 12.

The request in one picture

The rest of this page explains the following sequence. A route returns a render array, not a response, so the kernel’s VIEW event fires; a subscriber hands the array to the main content renderer for the negotiated format; for HTML the array becomes a page, then a document, and each themed element is turned into markup by the theme system.

sequenceDiagram participant B as Browser participant K as HttpKernel participant C as Controller participant S as MainContentViewSubscriber participant H as HtmlRenderer participant R as Renderer participant T as ThemeManager participant P as Preprocess callbacks participant W as Twig template B->>K: GET /visit/opening-hours K->>C: resolve route and call controller C-->>K: render array (not a Response) K->>S: VIEW event S->>H: renderResponse(main content) H->>H: build page array with regions and html array H->>R: renderRoot(html array) loop every element that names a theme hook R->>T: render(hook, variables) T->>P: initial, global, module and theme preprocess P-->>T: final variables T->>W: render template with variables W-->>T: markup string T-->>R: markup end R-->>H: HTML string plus bubbled cache metadata H-->>K: HtmlResponse K-->>B: 200 text/html

Step 1: a controller returns a render array

Routes whose controller returns a Symfony Response skip the pipeline entirely. Routes that return an array enter it. The controller below, from the meridian_collection module, returns the content of the "Opening hours" page. It names a theme hook (meridian_opening_hours), passes plain data and declares what the result depends on.

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Controller;

use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Datetime\DateFormatterInterface;
use Drupal\Core\Cache\Cache;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * Returns the public "Opening hours" page.
 */
final class OpeningHoursController extends ControllerBase {

  public function __construct(
    private readonly DateFormatterInterface $dateFormatter,
  ) {}

  public static function create(ContainerInterface $container): static {
    return new static($container->get('date.formatter'));
  }

  public function page(): array {
    $config = $this->config('meridian_collection.settings');

    $build = [
      '#theme' => 'meridian_opening_hours',
      '#days' => $config->get('opening_hours') ?? [],
      '#updated' => $this->dateFormatter->format((int) ($config->get('updated') ?? 0), 'custom', 'j F Y'),
      '#attached' => [
        'library' => ['meridian_collection/opening-hours'],
      ],
      '#cache' => [
        'tags' => ['config:meridian_collection.settings'],
        'contexts' => ['languages:language_interface'],
        'max-age' => Cache::PERMANENT,
      ],
    ];

    return $build;
  }

}
# modules/custom/meridian_collection/meridian_collection.routing.yml
meridian_collection.opening_hours:
  path: '/visit/opening-hours'
  defaults:
    _controller: '\Drupal\meridian_collection\Controller\OpeningHoursController::page'
    _title: 'Opening hours'
  requirements:
    _permission: 'access content'

Source: Render arrays

Routes, controllers and the title callbacks are covered in Routing and controllers. The important point here is the contract: the controller returns data and metadata, not HTML. The #updated value is a date formatted for the interface language, which is why the array declares the languages:language_interface cache context, and the days come from a configuration object, which is why the cache tag config:meridian_collection.settings is present. If the tag were missing, an editor who changed the opening hours would see the old values until the cache was cleared.

The value of updated is stored in the configuration object, so the array stays valid until that object changes. A value that changes with the clock, such as the current time, would be wrong in a permanently cached array: it belongs in a lazy builder (see the section on cacheability) or in JavaScript.

Step 2: the anatomy of a render array

A render array is an associative array. Keys that start with # are properties that control rendering. All other keys are children, which are render arrays themselves, so the structure is a tree. The properties that matter most for theming are the following.

Property Meaning

#type

Loads the defaults of a render element plugin (for example table, link, container, html_tag, form). It is shorthand for a predefined set of properties, including the #theme the element uses.

#theme

Names a theme hook. The theme system takes full responsibility for rendering the element, including its children, and expects the properties that the hook’s definition declares as variables (for example #days).

#markup

HTML supplied directly. Before it is output it is filtered with the administrator tag list (no <script> or <style>), unless an #allowed_tags list is given. Prefer #theme or #type for anything but very simple text.

#plain_text

Text that is escaped before output. It takes precedence over #markup when both are present.

#prefix, #suffix

Strings placed before and after the rendered element.

#attributes

The HTML attributes of elements and theme hooks that support them. They become an Attribute object in the template.

#weight

The order of children. Children are sorted before rendering, and #sorted is set when they already are.

#access

FALSE or an access result. A denied element renders as an empty string, and the cacheability of the access result still bubbles.

#attached

Libraries, drupalSettings, HTTP headers and HTML head elements that must accompany the markup.

#cache

The keys, contexts, tags, max-age and bin that make the element cacheable and describe what it depends on.

#pre_render

Trusted callbacks that change the array just before it is rendered. They may set #printed to abort.

#post_render

Callbacks that receive the rendered HTML and the array and may change the HTML.

#theme_wrappers

Theme hooks that wrap the children after they were placed in #children. Rarely combined with #theme.

#lazy_builder

A trusted callback and scalar arguments that build the element later, used for placeholders (see below).

#printed

Set by the renderer once an element has been output; a printed element is never output twice.

A pitfall that older tutorials spread is to build output with #markup and string concatenation. It bypasses theme hooks, so no template can be overridden; it concatenates user data into HTML, which risks cross-site scripting if the data is not escaped; and it hides cacheability, because a string cannot declare tags or contexts.

Source: Render arrays

Render elements and the render array of a page

A render element is a plugin (its class extends RenderElementBase) that supplies default properties for a #type. The element page represents the <body> contents and html the whole document. Core uses them in the HTML renderer described below, and a module can use '#type' => 'link' or '#type' => 'table' without knowing which template ends up rendering them. The Form API (see Form API) is the largest user of render elements, and the full property reference is the Render API and cache metadata page.

Step 3: from the array to a response

When a controller returns an array, the kernel dispatches KernelEvents::VIEW. The MainContentViewSubscriber looks at the negotiated request format and picks a main content renderer, a class that implements MainContentRendererInterface:

  • HtmlRenderer for text/html

  • AjaxRenderer for application/vnd.drupal-ajax

  • DialogRenderer for application/vnd.drupal-dialog

  • ModalRenderer for application/vnd.drupal-modal

If no renderer supports the requested format, the subscriber returns a 406 response whose JSON body lists the supported formats. That is also why a .json URL for an HTML-only route fails with a 406 instead of an empty page.

The HtmlRenderer performs these steps:

  1. prepare(): if the array is not already a '#type' => 'page' element, it asks for a page display variant by dispatching RenderEvents::SELECT_PAGE_DISPLAY_VARIANT. SimplePageVariant simply wraps the content; BlockPageVariant, used when the Block module is enabled, builds the regions of the active theme, fills them with the placed blocks and puts the main content in the "Main page content" block. Modules such as Page Manager select other variants per route.

  2. hook_page_attachments() and hook_page_attachments_alter() run to add page-level assets and head elements.

  3. renderResponse() wraps the page in a '#type' => 'html' element. hook_page_top() and hook_page_bottom() run here.

  4. The Renderer turns the html array into a string, using html.html.twig; this is the first point at which arrays become strings.

  5. The string is returned as an HtmlResponse, which carries the bubbled cache metadata as well as the attached assets.

During installation, updates, maintenance mode and some error states BareHtmlPageRenderer produces a simpler page that does not depend on the full theme system.

Step 4: theme hooks and the theme registry

A theme hook is the definition of how one kind of data is rendered. Modules register theme hooks in hook_theme(), declaring the hook name and its input variables (with defaults) or, for hooks that wrap a render array, a render element. Each theme hook needs a default Twig template, found by converting underscores in the hook name to hyphens: meridian_opening_hours is rendered by meridian-opening-hours.html.twig, looked up in the templates/ directory of the extension that defines the hook.

From Drupal 11.1 hook_theme() can be written as an object-oriented hook. The class below defines the theme hook of the example, sets the default values of its variables and registers the preprocess method that core calls first (see "The preprocess order").

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Hook;

use Drupal\Core\Hook\Attribute\Hook;

/**
 * Theme hooks of the Meridian collection module.
 */
final class ThemeHooks {

  /**
   * Implements hook_theme().
   */
  #[Hook('theme')]
  public function theme(): array {
    return [
      'meridian_opening_hours' => [
        'variables' => [
          'days' => [],
          'updated' => NULL,
        ],
        'initial preprocess' => static::class . ':preprocessOpeningHours',
      ],
    ];
  }

  /**
   * Prepares the variables of the opening hours template.
   *
   * @param array<string, mixed> $variables
   *   The template variables.
   */
  public function preprocessOpeningHours(array &$variables): void {
    $variables['rows'] = [];
    foreach ($variables['days'] as $day => $hours) {
      $variables['rows'][] = [
        'day' => $day,
        'open' => $hours['open'] ?? '',
        'close' => $hours['close'] ?? '',
        'closed' => empty($hours['open']),
      ];
    }
  }

}
{# modules/custom/meridian_collection/templates/meridian-opening-hours.html.twig #}
{#
/**
 * @file
 * Default template of the meridian_opening_hours theme hook.
 *
 * Available variables:
 * - rows: List of days, each with day, open, close and closed.
 * - updated: Formatted date of the last update.
 */
#}
<table{{ attributes.addClass('opening-hours') }}>
  <caption>{{ 'Opening hours'|t }}</caption>
  <thead>
    <tr>
      <th scope="col">{{ 'Day'|t }}</th>
      <th scope="col">{{ 'Hours'|t }}</th>
    </tr>
  </thead>
  <tbody>
    {% for row in rows %}
      <tr{% if row.closed %} class="opening-hours__closed"{% endif %}>
        <th scope="row">{{ row.day }}</th>
        <td>
          {% if row.closed %}
            {{ 'Closed'|t }}
          {% else %}
            {{ row.open }} &ndash; {{ row.close }}
          {% endif %}
        </td>
      </tr>
    {% endfor %}
  </tbody>
</table>
<p class="opening-hours__updated">{{ 'Updated @date'|t({'@date': updated}) }}</p>

The template does not use attributes or title_attributes unless the theme hook provides them: the theme system adds an empty Attribute object named attributes to every template (and to title_attributes and content_attributes when the hook uses them), so attributes.addClass('opening-hours') works even though the render array never declared #attributes.

Overriding a theme hook

A theme overrides a theme hook by placing a template of the same file name in its own templates/ directory. Copying the module template to themes/custom/meridian/templates/meridian-opening-hours.html.twig and clearing the cache is enough; no PHP is required. The override can live in any subdirectory of templates/, since the registry scans the directory recursively. A theme can also register theme hooks of its own in a hook_theme() implementation, but overriding is far more common.

The theme registry

Because discovering hooks, templates and preprocess functions on every request would be slow, Drupal builds a theme registry and caches it. The registry is built per active theme. The Registry class (service theme.registry) merges the definitions in this order: the hook_theme() results of all modules, then the base theme engines and base themes (parents first), then the active theme’s engine and the active theme itself, so later definitions override earlier ones. After that hook_theme_registry_alter() and the theme-level alter hook run.

A registry entry can contain the following keys:

Key Meaning

variables, render element

The default variables of the hook, or the name of the variable that holds the render array.

template, path

The template base name (without .html.twig) and the directory that contains it. The directory defaults to the templates folder of the extension.

base hook

For suggestions, the hook they extend, such as node for node__exhibition.

initial preprocess

The callback registered by the definition itself (see below).

preprocess functions

The compiled, ordered list of preprocess callbacks.

type, theme path

The kind of extension that defined the hook and its path, which a template sees as directory.

The registry is cached in several entries: a build shared by modules (theme_registry:build:modules), one complete registry for each theme (theme_registry:<theme>) and a lighter runtime registry used while rendering. It is rebuilt when the cache is cleared and when the set of installed extensions changes. This explains the most common surprise in theming: a template or preprocess function that was added to a theme is ignored until the registry is rebuilt.

# Rebuild all caches, including the theme registry
ddev drush cache:rebuild

# Same, using the short alias
ddev drush cr

While developing, enable Twig debugging so that templates are recompiled automatically and so that the HTML shows which templates were considered. From Drupal 10.1 the toggle is on the development settings page (/admin/config/development/settings); in older sites and in code the same switch is the container parameter twig.config, set in a file that is only loaded locally:

# sites/development.services.yml (loaded from settings.local.php, never on production)
parameters:
  twig.config:
    debug: true
    auto_reload: true

The comments that debug mode prints (the theme hook, the file name suggestions and the template that produced each chunk) are explained in Twig templates and debugging. Never leave debug mode on in production: it prints internal paths in the HTML and disables template caching.

Step 5: preprocess

Before a template runs, the theme system builds its variables. Preprocess callbacks exist so that the template contains presentation only and the logic lives in PHP, where it can be tested. The order in which the callbacks run is exactly the order of the list below, and it determines who can override whom.

  1. The default template variables that core adds to every hook.

  2. The initial preprocess callback set by the hook definition (the initial preprocess key; since 11.2 the replacement for the magic template_preprocess_HOOK() functions, which are deprecated in 11.3 and removed in 12).

  3. Global preprocess callbacks for all hooks: MODULE_preprocess(&$variables, $hook).

  4. Preprocess callbacks for this hook in modules: MODULE_preprocess_HOOK(&$variables), or the equivalent object-oriented #[Hook('preprocess_HOOK')] method.

  5. The theme engine’s callbacks, ENGINE_engine_preprocess and ENGINE_engine_preprocess_HOOK.

  6. Preprocess callbacks of the base themes and, last, those of the active theme: THEME_preprocess and THEME_preprocess_HOOK.

Because the active theme runs last, a theme can always adjust what a module prepared. Afterwards the theme system converts any of attributes, title_attributes and content_attributes that are still plain arrays into Attribute objects and exposes the suggestion list as theme_hook_suggestions.

flowchart TD A["Render array naming a theme hook"] --> B["Registry lookup: hook and suggestions"] B --> C["Default variables"] C --> D["Initial preprocess callback"] D --> E["Global preprocess: MODULE_preprocess"] E --> F["Module preprocess: MODULE_preprocess_HOOK"] F --> G["Base theme and active theme preprocess"] G --> H["Attributes arrays become Attribute objects"] H --> I["Twig template renders the variables"] I --> J["Markup string with bubbled metadata"]

Preprocess in modules and in themes

Module preprocess hooks can be written as methods of an object-oriented hook class from Drupal 11.2, and themes support object-oriented hooks from 11.3 using the same #[Hook] attribute in a class under the theme’s src/Hook/ directory. Themes cannot use the ordering parameters of the attribute (order, module), ReorderHook or RemoveHook; base theme hooks always run before the active theme’s hooks. A theme that must also run on Drupal versions before 11.2 keeps the procedural function and marks the class method as the primary implementation with #[LegacyHook] on the function.

<?php

declare(strict_types=1);

namespace Drupal\meridian\Hook;

use Drupal\Core\Hook\Attribute\Hook;

/**
 * Preprocess hooks of the Meridian theme.
 */
final class MeridianPreprocess {

  /**
   * Adds a body class and the opening hours link to every page.
   *
   * @param array<string, mixed> $variables
   *   The page template variables.
   */
  #[Hook('preprocess_page')]
  public function preprocessPage(array &$variables): void {
    $variables['site_slogan_short'] = 'Science for everyone';
  }

  /**
   * Adds the theme's component library only where it is needed.
   *
   * @param array<string, mixed> $variables
   *   The opening hours template variables.
   */
  #[Hook('preprocess_meridian_opening_hours')]
  public function preprocessOpeningHours(array &$variables): void {
    $variables['#attached']['library'][] = 'meridian/opening-hours-table';
    $variables['attributes']['class'][] = 'opening-hours--museum';
  }

}

The older, procedural form still works and is still what most existing themes contain:

<?php

/**
 * @file
 * Functions to support the Meridian theme.
 */

/**
 * Implements hook_preprocess_HOOK() for page templates.
 */
function meridian_preprocess_page(array &$variables): void {
  $variables['site_slogan_short'] = 'Science for everyone';
}

The same variables are available under both forms. New code written for Drupal 11.3 or later uses the class form, which can receive services through the constructor (see Hooks and object-oriented hooks and Services and dependency injection). Older books and tutorials call services from .theme files with \Drupal::service(); in an object-oriented hook class that call is replaced by constructor injection.

Attaching assets and cache metadata from preprocess

Preprocess functions may set $variables['#attached'] and $variables['#cache'], and core merges them like the properties of a render array. This is the officially supported way to attach libraries and cacheability to the template that is being preprocessed, as the opening hours example above does. Preprocess functions may not define cacheable elements: a keys entry in $variables['#cache'] is ignored. Anything that depends on the current user, the route or a configuration object must add the right context or tag here, or the page will be cached with the variables of the first visitor.

Suggestions

A template can be selected by a more specific name. A theme hook called as item_list__search_results looks for item-list--search-results.html.twig and falls back to item-list.html.twig; the double underscore in hook names becomes a double hyphen in the file name. Suggestions are added with an array in #theme, with a hook_theme_suggestions_HOOK() implementation or with hook_theme_suggestions_HOOK_alter() and hook_theme_suggestions_alter(). The highest-priority suggestion is the last in the list and the first one that exists in the registry wins. Preprocess functions for the base hook run for all of its suggestions. The topic has its own page, Template suggestions and preprocess; the point here is that suggestions are resolved after the render array was written, so a controller never needs to know about them.

Step 6: the Twig template

Twig is the template engine of Drupal; the sandbox, the filters and the functions that Drupal adds are covered in Twig templates and debugging and Twig extensions in modules. Four properties of Twig matter for the pipeline.

  • Autoescaping is on. Every {{ variable }} is escaped according to its type. Strings that are safe markup (render output, Markup objects, translated strings built with placeholders) are printed as they are; plain strings are escaped. The |raw filter turns escaping off and is an XSS risk if it is applied to anything that came from a user. A template that needs to print stored HTML must receive it from a text format that filters it (see Text formats and CKEditor 5).

  • Templates have no side effects. They receive variables and print them. They cannot run arbitrary PHP: method calls on objects are restricted by a sandbox policy. Core 11.2 narrowed the default allow list, so id(), label() and bundle() are allowed only on entities. A template that breaks after an upgrade because it calls another method has three fixes: add a preprocess method, write a Twig filter, or (rarely) add the method to $settings['twig_sandbox_allowed_methods'] in settings.php.

  • Twig is compiled and cached. A template is compiled to PHP on first use and stored in the php storage; changes to a template are noticed after the cache is rebuilt, or at once when auto_reload is on.

  • The trans filter and tag translate strings ({{ 'Day'|t }}); strings in templates are collected for the interface translation like strings in PHP.

Core does not ship the spaceless filter in its own templates any more (11.1), and Twig functions such as html_cva and html_classes arrived with the twig/html-extra package in 11.4. Both are covered in the Twig pages.

The variables of a template

Every template starts with a docblock that lists the variables it receives. Reading it is the fastest way to learn the contract. A render array property such as '#days' becomes the variable days; the property '#attributes' becomes attributes; children of the array are available as content for hooks that use render element, and printing {{ content }} prints all of them. Individual children are printed with {{ content.field_image }}, and the without filter prints the rest of a render array without some of its children:

{# themes/custom/meridian/templates/content/node--exhibition--teaser.html.twig #}
<article{{ attributes.addClass('exhibition-teaser') }}>
  {{ content.field_hero_image }}
  <h2{{ title_attributes }}>
    <a href="{{ url }}" rel="bookmark">{{ label }}</a>
  </h2>
  <div{{ content_attributes.addClass('exhibition-teaser__body') }}>
    {{ content|without('field_hero_image', 'links') }}
  </div>
  {{ content.links }}
</article>

The name node--exhibition--teaser.html.twig is a template suggestion for the node hook, content type exhibition, view mode teaser. A subtle point that surprises beginners is that printing a field ({{ content.field_hero_image }}) renders the field’s own theme hooks and their templates, and bubbles the field’s cache metadata into this template’s output. Printing the same field twice is harmless, but the renderer marks a rendered child as printed, so the second use may print nothing when the child was rendered earlier; copy the value you need into a separate variable in preprocess instead.

Step 7: regions, the page template and the html template

The final layers are the most visible to a themer.

The html template

html.html.twig produces the document: the doctype, the html element, the head and the body. It exposes the variables html_attributes, head_title, page_top, page and page_bottom, plus placeholder_token, logged_in, root_path, node_type and db_offline for building body classes. Starterkit’s version of the template looks like this (shortened):

{# themes/custom/meridian/templates/layout/html.html.twig #}
{%
  set body_classes = [
    logged_in ? 'user-logged-in',
    not root_path ? 'path-frontpage' : 'path-' ~ root_path|clean_class,
    node_type ? 'page-node-type-' ~ node_type|clean_class,
    db_offline ? 'db-offline',
  ]
%}
<!DOCTYPE html>
<html{{ html_attributes }}>
  <head>
    <head-placeholder token="{{ placeholder_token }}">
    <title>{{ head_title|safe_join(' | ') }}</title>
    <css-placeholder token="{{ placeholder_token }}">
    <js-placeholder token="{{ placeholder_token }}">
  </head>
  <body{{ attributes.addClass(body_classes) }}>
    <a href="#main-content" class="visually-hidden focusable skip-link">
      {{ 'Skip to main content'|t }}
    </a>
    {{ page_top }}
    {{ page }}
    {{ page_bottom }}
    <js-bottom-placeholder token="{{ placeholder_token }}">
  </body>
</html>

The head-placeholder, css-placeholder and js-placeholder elements are replaced after rendering by the CSS, the JavaScript and the head elements collected from #attached of the whole page. That is why a template cannot print a <link> or <script> itself: assets are attached as libraries and Drupal decides where to emit them, in which order and whether to aggregate them (see Libraries and assets). The skip link is part of the accessibility contract of the theme; do not remove it (see Accessibility and performance in themes).

The page template and regions

page.html.twig produces what goes inside the body. Its main variable is page, an array of regions: each region of the theme’s regions list appears as page.header, page.content, page.sidebar_first and so on, and printing one prints every block placed in it. The template that follows is the page template of the meridian theme, reduced to its structure:

{# themes/custom/meridian/templates/layout/page.html.twig #}
<div class="layout-container">

  <header class="site-header">
    {{ page.header }}
  </header>

  {{ page.primary_menu }}
  {{ page.secondary_menu }}
  {{ page.breadcrumb }}
  {{ page.highlighted }}
  {{ page.help }}

  <main>
    <a id="main-content" tabindex="-1"></a>

    <div class="layout-content">
      {{ page.content }}
    </div>

    {% if page.sidebar_first %}
      <aside class="layout-sidebar-first">
        {{ page.sidebar_first }}
      </aside>
    {% endif %}
  </main>

  {% if page.footer %}
    <footer class="site-footer">
      {{ page.footer }}
    </footer>
  {% endif %}

</div>

Points to remember:

  • A region renders to an empty array when no block is placed in it, which Twig treats as false. That is why the template wraps optional regions in {% if page.footer %}: the wrapper element disappears when the region is empty, instead of leaving an empty <footer>.

  • The region machine names in the template must match the keys of regions in the info file. A region that is listed in the info file but never printed in page.html.twig accepts blocks that never appear; the Block layout page still lets an administrator place them, which looks like a bug in the block system and is in fact a template omission.

  • page.content contains the main content block plus any block placed in the content region. The main content block is the output of the controller from step 1; the page display variant put it there.

  • A region has its own theme hook (region) with its own template, region.html.twig, so wrapper markup per region can be changed without touching page.html.twig. Block templates (block.html.twig) work the same way.

  • The page variable also feeds the page preprocess; core’s own implementation lives in Drupal\Core\Theme\ThemePreprocess::preprocessPage(), an internal class that also contains the html, region, links and table preprocessing for the core templates.

Block placement, visibility and the block plugin system are described in Blocks and block layout, and the region declarations in Theme info files and regions. Pages built with Layout Builder or Canvas bypass the fixed regions inside the content area: Layout Builder stores a per-entity layout (see Layout Builder) and Canvas renders Single Directory Components and code components (see Canvas visual page building). Both still end up inside the page.content region of the active theme, so the theme’s page template, spacing and typography apply to them.

Attribute objects

Template variables such as attributes, title_attributes and content_attributes are Drupal\Core\Template\Attribute objects. They print as a string of correctly escaped HTML attributes and offer methods that avoid string concatenation in templates.

Usage in Twig Effect

<div{{ attributes }}>

Prints all attributes. There is no space between the tag name and {{, because the object prints its own leading space.

attributes.addClass('card')

Adds one class or an array of classes and returns the object, so calls can be chained.

attributes.removeClass('green')

Removes a class.

attributes.hasClass('card')

Returns true when the class is present, typically in an if test.

attributes.setAttribute('id', 'hero')

Sets an attribute and replaces an existing value.

attributes.removeAttribute('id')

Removes an attribute.

attributes|without('class')

Prints all attributes except those listed.

attributes.style, attributes['data-gallery']

Reads one value. Names with hyphens need the bracket form. There is no getAttribute().

create_attribute({'class': ['card']})

Creates a new object from an array. Use it for markup the template builds itself.

{# themes/custom/meridian/templates/block/block--meridian-ondisplay.html.twig #}
{%
  set classes = [
    'block',
    'block-' ~ configuration.provider|clean_class,
    'block-' ~ plugin_id|clean_id,
  ]
%}
<div{{ attributes.addClass(classes) }}>
  {{ title_prefix }}
  {% if label %}
    <h2{{ title_attributes.addClass('block__title') }}>{{ label }}</h2>
  {% endif %}
  {{ title_suffix }}
  {% block content %}
    {% set badge = create_attribute({'class': ['block__badge'], 'data-count': content|length}) %}
    <span{{ badge }}>{{ 'Objects on display'|t }}</span>
    <div{{ content_attributes.addClass('block__content') }}>
      {{ content }}
    </div>
  {% endblock %}
</div>

Three rules keep attributes safe. Always let the object print the attributes instead of writing class="..." by hand (a hand-written value can be unescaped); if you build an attribute value by hand, wrap it in double quotes, because an unquoted value can allow script injection. The {% set attributes = attributes.addClass('x') %} form works for variables, but for an object inside an array (such as row.attributes) use the do tag: {% do row.attributes.addClass('x') %}. And do not add class arrays in preprocess when a template can add them: an attribute added in preprocess applies to every template that uses the variable, including those a base theme provides.

Modifying attributes in preprocess uses plain arrays, because the conversion to Attribute happens after preprocess:

function meridian_preprocess_block(array &$variables): void {
  if ($variables['plugin_id'] === 'meridian_collection_on_display') {
    $variables['attributes']['class'][] = 'block--on-display';
    $variables['attributes']['data-source'] = 'collection';
  }
}

Cacheability in templates

Rendering is also the moment at which caching decisions are made, and the template layer is where most mistakes enter. Every render array carries cache metadata that describes the result:

  • cache keys identify a cacheable representation, such as ['node', 5, 'teaser']; they are set only on arrays that should be cached;

  • cache contexts are the request-dependent variations, such as user, languages:language_interface, url.path or route; they work like the HTTP Vary header;

  • cache tags are the data the output depends on, such as node:5 or config:system.site; changing the data invalidates every entry with the tag;

  • max-age is a lifetime in seconds. It defaults to permanent, and 0 means the output cannot be cached at all.

Contexts, tags, max-age and attachments bubble: when a child is rendered, its metadata is merged into every parent up to the response, so the page’s final headers describe everything on it. The practical rules for themers are these.

  • Print fields and render arrays with {{ content.field_x }} so that their metadata bubbles. Printing a value that you copied out of an entity ({{ node.field_x.value }}) bypasses the field formatter, the text format and the cache metadata. If the underlying data changes, the cached page does not.

  • Do not call services from a template, and do not turn render arrays into strings inside preprocess (for example with the render service) unless you are rendering in isolation and merge the metadata back with addCacheableDependency(). A string has no cache metadata; the page is cached as if the dependency did not exist, and a rarely seen, hard-to-reproduce stale-content bug follows.

  • If a template output varies per user, role, language, URL or query argument, the render array must declare the matching cache context. A missing user context in a block that shows the visitor’s name displays one visitor’s name to the next.

  • Use $variables['#cache'] and $variables['#attached'] in preprocess to add dependencies of your variables.

  • A max-age of 0 anywhere makes the entire page uncacheable in the Internal Page Cache and Dynamic Page Cache, because max-age bubbles as the minimum. Unavoidable dynamic fragments belong in a lazy builder.

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Render;

use Drupal\Core\Security\TrustedCallbackInterface;
use Drupal\Core\Session\AccountProxyInterface;

/**
 * Greeting for logged-in members, rendered as a placeholder.
 */
final class MemberGreeting implements TrustedCallbackInterface {

  public function __construct(
    private readonly AccountProxyInterface $currentUser,
  ) {}

  /**
   * Builds the member greeting.
   *
   * This is the lazy builder: the rest of the page stays cacheable while the
   * greeting is generated per user at the last moment.
   *
   * @return array<string, mixed>
   *   A render array that varies by user.
   */
  public function build(): array {
    return [
      '#markup' => $this->currentUser->isAuthenticated()
        ? 'Welcome back, ' . $this->currentUser->getDisplayName()
        : 'Welcome to the Meridian Museum',
      '#cache' => ['contexts' => ['user']],
    ];
  }

  public static function trustedCallbacks(): array {
    return ['build'];
  }

}
# modules/custom/meridian_collection/meridian_collection.services.yml
services:
  meridian_collection.member_greeting:
    class: Drupal\meridian_collection\Render\MemberGreeting
    arguments: ['@current_user']
// In a block plugin or controller: reference the lazy builder service.
$build['greeting'] = [
  '#lazy_builder' => ['meridian_collection.member_greeting:build', []],
  '#create_placeholder' => TRUE,
];

A lazy builder is a two-element array: a trusted callback (a service and method written service_id:method, or a static callable) and a list of scalar arguments. The element may have no other properties and no children. When the renderer meets it in a cacheable response it can replace it with a placeholder, finish the rest of the page, and render the placeholder at the very end, so the page can be stored in the Dynamic Page Cache even though a fragment varies per user. Drupal creates placeholders automatically when a lazy builder’s metadata meets one of the conditions in the renderer.config container parameter: by default a max-age of 0 or the contexts session and user, and no tags. Before 10.5, a lazy builder could not return a #type element directly; the result had to use #theme, #markup or #plain_text, or wrap the element in a child. The BigPipe module streams placeholders to the browser after the initial HTML, so a slow fragment no longer delays the first byte. The Render API and the caching layers are covered in Render API and cache metadata and Performance and caching.

Two debugging aids make the metadata visible. With development settings enabled, the response headers X-Drupal-Cache-Contexts and X-Drupal-Cache-Tags list the contexts and tags of the page; and with render cache debugging the cacheability appears as comments in the HTML. The page-level contexts that Drupal always adds are shown there too.

The order of operations inside the renderer

When Renderer::doRender() processes one element, the order is: return an empty string for an empty element; evaluate #access; skip an element already #printed; read the render cache if #cache has keys; apply the defaults of #type; validate and perform lazy building or auto-placeholdering; run each #pre_render callback; filter #markup and #plain_text; sort and render the children; render the element through its #theme (preprocess and Twig as described above) or prepend #markup to the children; apply #theme_wrappers; run #post_render callbacks; wrap the result in #prefix and #suffix; write the render cache; bubble the metadata and mark the element #printed. The practical conclusions: access is checked before anything else, so a denied element costs almost nothing; #pre_render is the right place to alter an array that came from elsewhere because it runs before the cache is written; and a cache key must not change between the read and the write or the renderer throws an exception.

Drupal’s markup philosophy: Stable 9, Starterkit and no Classy

Drupal’s core templates output a lot of markup because they are written for everyone: every element has structural classes and wrappers that the administration theme and contributed modules rely on. A theme has historically had two choices: inherit the markup of a core base theme, or copy it. Older tutorials from the Drupal 8 and 9 period describe a third piece, the Classy theme, which added many semantic CSS classes to the markup (node__content, field--name-*, block-system-main-block) on top of Stable. The history is relevant because many snippets on the web still refer to those classes.

  • Classy and Stable were removed from core in Drupal 10.0. Neither is a dependency of new work. Stable ("Stable 8") was replaced by the Starterkit theme generator, and the change record offers three options to existing themes: use the contributed Stable theme, switch the base theme to Stable 9, or set base theme: false and copy the templates, CSS, JavaScript and libraries the theme needs.

  • Stable 9 is the version that kept the markup stability promise for existing sub-themes. It is deprecated as of 11.4 (the status report may warn about it even when the contributed module is installed) and leaves core in Drupal 12. Do not build a new theme on it.

  • Starterkit is a core theme (starterkit_theme) that serves as a template for generating new themes. The generator copies the Starterkit’s templates and CSS into your project and renames them. A generated theme is standalone: Starterkit declares base theme: false, and the file records the version it was generated from in its generator key so that later upstream changes can be compared and adopted by hand. A theme that was generated from Starterkit in 11.x does not receive markup changes automatically; this is a conscious trade-off, because markup stability becomes the project’s own responsibility.

  • Classy is not a base theme. A theme that wants Classy-like class names has to include them in its own templates or add them in preprocess with addClass(). The class lists in older Classy documentation are obsolete.

The command that generates the meridian theme from the Starterkit changed in 11.4. Until 11.3 it is php core/scripts/drupal generate-theme; from 11.4 the entry point is core/scripts/dr, installed as vendor/bin/dr, and calling drupal is deprecated in 11.4 and removed in 13.0.0:

# Drupal 11.4 and later
ddev exec vendor/bin/dr generate-theme meridian \
  --path themes/custom \
  --name "Meridian" \
  --description "Theme of the Meridian Museum site"

# Drupal 11.3 and earlier
ddev exec php web/core/scripts/drupal generate-theme meridian \
  --path themes/custom \
  --name "Meridian"

# Make it the default theme
ddev drush theme:install meridian
ddev drush config:set system.theme default meridian -y
ddev drush cr

The options of the generator are --starterkit (the source theme, starterkit_theme by default), --path (default themes), --name and --description. A theme can act as a starterkit for others if it includes a *.starterkit.yml file, which lists files to leave out of the copy (ignore), files whose contents are not edited (no_edit) and files whose names are not changed (no_rename). The details of sub-themes, base-theme choices in the Drupal 12 era and the generator’s options are in Starterkit and sub-themes.

The drupal.org page for sub-themes still shows examples with base theme: stable9 and describes a generated theme as a sub-theme of Stable 9, while the Starterkit source in core declares the theme standalone; a drupal.org planning issue about the base theme value of Starterkit, raised because Stable 9 leaves core in Drupal 12, was closed as outdated in March 2026. A project should read the *.info.yml file that the generator actually produced and treat that as authoritative.

Single Directory Components in the pipeline

Single Directory Components (SDC) do not replace the pipeline; they plug into it. A component is a directory under the theme or module components/ folder with a *.component.yml file, a Twig template and optional CSS and JavaScript. Components have been part of the core render system since Drupal 10.3, so there is no module to enable. A component is rendered by a render element of #type component, or from Twig with include or embed using the namespace meridian:card; its CSS and JavaScript are attached automatically. Component props are validated with JSON Schema.

$build['hero'] = [
  '#type' => 'component',
  '#component' => 'meridian:hero',
  '#props' => [
    'heading' => 'Light and shadow',
    'subheading' => 'Spring exhibition',
    'image' => [
      'src' => '/themes/custom/meridian/images/hero.jpg',
      'alt' => 'Visitors in the main hall',
    ],
  ],
  '#slots' => [
    'actions' => ['#markup' => 'Buy tickets'],
  ],
];

Older books such as Modernizing Drupal 10 Theme Development (2023) present SDC as an experimental module with library names starting with sdc/ and a schema under core/modules/sdc; since 10.3 they are part of core and the details differ. See Single Directory Components.

Common errors and pitfalls

Symptom Cause and remedy

A new template or preprocess function has no effect

The theme registry was not rebuilt. Run drush cr. If the file name is right but nothing changes, enable Twig debugging and read which file name suggestions were considered.

The template is ignored, but the theme hook exists

The file name does not match: underscores in the hook name must be hyphens, double underscores must be double hyphens, and the file must end in .html.twig.

A region never appears

It is in regions of the info file but not printed in page.html.twig, or its name differs by a typo. Remember that if the info file defines regions at all, Drupal does not add the default ones.

Wrong content shown to different users

A render array depends on the user, role, language or URL without the cache context. Add the context to the array and test with two browsers or drush cr between requests.

Content is stale after an edit

A missing cache tag. Print fields through content, add addCacheableDependency() for objects used in preprocess, or the right tag for configuration (config:...).

A custom variable is empty in the template

It was set in a preprocess hook for the wrong theme hook name, or the module’s hook ran after the theme’s. Dump the variables with {{ dump(_context|keys) }} and check the suggestion that was used.

A LogicException about #lazy_builder

A #lazy_builder element has other properties or children, or auto-placeholdering was requested without a lazy builder. Wrap the element in a parent array instead.

A LogicException about a missing render context

render() was called outside a render context (for example in a CLI command). Use renderInIsolation() or renderRoot() and keep the metadata.

Raw <script> text in output

#markup strips scripts. Attach a library instead; for inline settings use drupalSettings in #attached.

XSS through attributes

Hand-written class="{{ value }}" with a user value, or a Twig |raw. Use addClass() and Attribute.

Version notes

  • Drupal 10.0 removed Classy and Stable and introduced Starterkit as the way to start a theme; CKEditor 4 was already gone. Stable 9 was added for sites that depended on stable markup.

  • 10.1 added the Twig debug toggle to the development settings screen.

  • 10.3 included Single Directory Components in the core render system; renderPlain() is deprecated since 10.3 and removed in 12.0.0 (use renderInIsolation()).

  • 11.1 introduced object-oriented hooks for modules (#[Hook] methods in src/Hook); spaceless was removed from core templates.

  • 11.2 allowed preprocess hooks in classes, introduced the initial preprocess key and narrowed the Twig sandbox default.

  • 11.3 allowed object-oriented hooks in themes, and deprecated the magic template_preprocess_HOOK() functions and the file and includes keys of hook_theme(), all removed in 12.0.

  • 11.4 deprecated Stable 9, made vendor/bin/dr the entry point for generating themes and added the html_cva and html_classes Twig functions (through twig/html-extra).

  • Drupal 12.0.0 (scheduled for the week of 2026-12-07) removes Claro, Olivero and Stable 9 from core, with contributed replacements; plan the base-theme and administration-theme choices before upgrading.

Details for each version are in Versions, release cycle and support.

Where older books and tutorials are outdated

  • Drupal 10 Development Cookbook (2023) and older tutorials implement preprocess and theme hooks as functions in a .module or .theme file and call \Drupal:: services from them. That still works, but since 11.1 modules and since 11.3 themes can use classes with #[Hook] attributes and constructor injection, and the core template_preprocess_* convention is deprecated in favour of initial preprocess.

  • Modernizing Drupal 10 Theme Development (2023) targets Drupal 10.1, so it predates Drupal 11, the object-oriented theme hooks of 11.3, the 11.4 generator command and the removal of Stable 9 from core; treat SDC and \Drupal:: calls in .theme code there as superseded.

  • Tutorials for Drupal 8 and 9 tell the reader to use classy or stable as base theme, to write .tpl.php or PHPTemplate files, or to expect Classy class names. None applies to Drupal 10 and later.

  • Material that uses drupal_render() or render() as global functions refers to code that was replaced by the renderer service (renderRoot(), renderInIsolation()).

  • Some tutorials put <link> and <script> tags in templates. Assets are attached through libraries.

Security notes

  • Do not use |raw on user data and do not build HTML with string concatenation in #markup.

  • Unquoted or hand-written attribute values can enable script injection; use Attribute.

  • #markup is filtered with the administrator tag list; #plain_text is escaped completely. Choose #plain_text for anything that is not meant to be HTML.

  • Twig debug output reveals file paths and template names; keep it out of production.

  • Anything in a cache context that is missing is a confidentiality risk, not just a performance risk: a block that shows data of the current user and is cached without the user context discloses it to others. See Security hardening.

  • Lazy builders and #pre_render callbacks must be trusted callbacks (a service callable or a class implementing TrustedCallbackInterface); arbitrary callables from user input are refused.