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.ymlfile, 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 |
|---|---|
|
Metadata, the base theme, the regions, the libraries loaded on every page and the removal or replacement of module CSS. |
|
The CSS and JavaScript libraries that the theme defines. Files in |
|
Breakpoints for responsive layouts and responsive images. See Breakpoints and responsive images. |
|
Procedural PHP for preprocess and alter hooks, and for extending the theme settings form. |
|
Object-oriented hook classes. Themes support them from Drupal 11.3 (see below). |
|
Twig templates. A file named after a template of a module or of a base theme overrides it. |
|
Single Directory Components. See Single Directory Components. |
|
Default theme settings and their schema. See Theme settings and configurable themes. |
|
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.
Source: Drupal theme folder structure
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.
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 |
|---|---|
|
Loads the defaults of a render element plugin (for example |
|
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 |
|
HTML supplied directly. Before it is output it is filtered with the administrator tag list (no |
|
Text that is escaped before output. It takes precedence over |
|
Strings placed before and after the rendered element. |
|
The HTML attributes of elements and theme hooks that support them. They become an |
|
The order of children. Children are sorted before rendering, and |
|
|
|
Libraries, |
|
The keys, contexts, tags, max-age and bin that make the element cacheable and describe what it depends on. |
|
Trusted callbacks that change the array just before it is rendered. They may set |
|
Callbacks that receive the rendered HTML and the array and may change the HTML. |
|
Theme hooks that wrap the children after they were placed in |
|
A trusted callback and scalar arguments that build the element later, used for placeholders (see below). |
|
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:
-
HtmlRendererfortext/html -
AjaxRendererforapplication/vnd.drupal-ajax -
DialogRendererforapplication/vnd.drupal-dialog -
ModalRendererforapplication/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:
-
prepare(): if the array is not already a'#type' => 'page'element, it asks for a page display variant by dispatchingRenderEvents::SELECT_PAGE_DISPLAY_VARIANT.SimplePageVariantsimply 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. -
hook_page_attachments()andhook_page_attachments_alter()run to add page-level assets and head elements. -
renderResponse()wraps the page in a'#type' => 'html'element.hook_page_top()andhook_page_bottom()run here. -
The
Rendererturns thehtmlarray into a string, usinghtml.html.twig; this is the first point at which arrays become strings. -
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.
Source: The Drupal 8 render pipeline
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 }} – {{ row.close }}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<p class="opening-hours__updated">{{ 'Updated @date'|t({'@date': updated}) }}</p>
Source: Theme system overview (api.drupal.org) and Change record: template_preprocess_HOOK are defined as callbacks in the theme hook
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 |
|---|---|
|
The default variables of the hook, or the name of the variable that holds the render array. |
|
The template base name (without |
|
For suggestions, the hook they extend, such as |
|
The callback registered by the definition itself (see below). |
|
The compiled, ordered list of preprocess callbacks. |
|
The kind of extension that defined the hook and its path, which a template sees as |
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
Source: Registry class (api.drupal.org)
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
Source: Debugging Twig templates
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.
-
The default template variables that core adds to every hook.
-
The initial preprocess callback set by the hook definition (the
initial preprocesskey; since 11.2 the replacement for the magictemplate_preprocess_HOOK()functions, which are deprecated in 11.3 and removed in 12). -
Global preprocess callbacks for all hooks:
MODULE_preprocess(&$variables, $hook). -
Preprocess callbacks for this hook in modules:
MODULE_preprocess_HOOK(&$variables), or the equivalent object-oriented#[Hook('preprocess_HOOK')]method. -
The theme engine’s callbacks,
ENGINE_engine_preprocessandENGINE_engine_preprocess_HOOK. -
Preprocess callbacks of the base themes and, last, those of the active theme:
THEME_preprocessandTHEME_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.
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,Markupobjects, translated strings built with placeholders) are printed as they are; plain strings are escaped. The|rawfilter 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()andbundle()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']insettings.php. -
Twig is compiled and cached. A template is compiled to PHP on first use and stored in the
phpstorage; changes to a template are noticed after the cache is rebuilt, or at once whenauto_reloadis on. -
The
transfilter 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
regionsin the info file. A region that is listed in the info file but never printed inpage.html.twigaccepts 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.contentcontains 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 touchingpage.html.twig. Block templates (block.html.twig) work the same way. -
The
pagevariable also feeds thepagepreprocess; core’s own implementation lives inDrupal\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 |
|---|---|
|
Prints all attributes. There is no space between the tag name and |
|
Adds one class or an array of classes and returns the object, so calls can be chained. |
|
Removes a class. |
|
Returns true when the class is present, typically in an |
|
Sets an attribute and replaces an existing value. |
|
Removes an attribute. |
|
Prints all attributes except those listed. |
|
Reads one value. Names with hyphens need the bracket form. There is no |
|
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>
Source: Using attributes in templates
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.pathorroute; they work like the HTTPVaryheader; -
cache tags are the data the output depends on, such as
node:5orconfig:system.site; changing the data invalidates every entry with the tag; -
max-age is a lifetime in seconds. It defaults to permanent, and
0means 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
renderservice) unless you are rendering in isolation and merge the metadata back withaddCacheableDependency(). 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
usercontext 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-ageof 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,
];
Source: Cacheability of render arrays and Auto-placeholdering
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.
Source: Renderer class (api.drupal.org)
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: falseand 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 declaresbase theme: false, and the file records the version it was generated from in itsgeneratorkey 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
Source: Starterkit theme
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 |
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 |
A region never appears |
It is in |
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 |
Content is stale after an edit |
A missing cache tag. Print fields through |
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 |
A |
A |
A |
|
Raw |
|
XSS through attributes |
Hand-written |
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 (userenderInIsolation()). -
11.1 introduced object-oriented hooks for modules (
#[Hook]methods insrc/Hook);spacelesswas removed from core templates. -
11.2 allowed preprocess hooks in classes, introduced the
initial preprocesskey and narrowed the Twig sandbox default. -
11.3 allowed object-oriented hooks in themes, and deprecated the magic
template_preprocess_HOOK()functions and thefileandincludeskeys ofhook_theme(), all removed in 12.0. -
11.4 deprecated Stable 9, made
vendor/bin/drthe entry point for generating themes and added thehtml_cvaandhtml_classesTwig functions (throughtwig/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
.moduleor.themefile 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 coretemplate_preprocess_*convention is deprecated in favour ofinitial 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.themecode there as superseded. -
Tutorials for Drupal 8 and 9 tell the reader to use
classyorstableasbase theme, to write.tpl.phpor PHPTemplate files, or to expect Classy class names. None applies to Drupal 10 and later. -
Material that uses
drupal_render()orrender()as global functions refers to code that was replaced by therendererservice (renderRoot(),renderInIsolation()). -
Some tutorials put
<link>and<script>tags in templates. Assets are attached through libraries.
A consolidated list is in Where older books and tutorials are outdated.
Security notes
-
Do not use
|rawon user data and do not build HTML with string concatenation in#markup. -
Unquoted or hand-written attribute values can enable script injection; use
Attribute. -
#markupis filtered with the administrator tag list;#plain_textis escaped completely. Choose#plain_textfor 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
usercontext discloses it to others. See Security hardening. -
Lazy builders and
#pre_rendercallbacks must be trusted callbacks (a service callable or a class implementingTrustedCallbackInterface); arbitrary callables from user input are refused.