Drupal Template Suggestions and Preprocess Hooks
|
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. |
This page was checked on 2026-10-30 against Drupal 11.4.x, Drush 13/14 and PHP 8.3+. It is written for the themer and the developer. A themer learns here how to make Drupal choose a different Twig file for one particular situation and how to hand extra values to that file. A developer learns where the code lives, which hooks may be written as classes, and how to keep the page cache honest when a template depends on more than the entity being rendered.
The running example is the Meridian Museum site: a theme called meridian (generated with Starterkit, see
Starterkit and subthemes) and a module called meridian_collection. The
museum wants exhibition pages that look different from news, a visual treatment for exhibitions that are about to close, a
collection accession number rendered in a monospaced field template, and a "curator notes" link that only some staff
see.
The two jobs: choosing a template and preparing variables
Rendering a themed element in Drupal involves two separate decisions that are often confused:
-
Which template file renders the element. The element names a theme hook (for example
node,block,field,page,views_view). Drupal looks for a template file that matches the theme hook, and, if the element or an alter hook supplied suggestions, for more specific files first. Choosing the template is the job of theme hook suggestions. -
Which variables the template receives. Before the template runs, a chain of preprocess functions gets a chance to add, change or remove entries of the
$variablesarray. Preparing variables is the job of preprocess hooks.
The two are independent. A suggestion decides which file runs; preprocess decides what is inside the file’s variables. The only link between them is the order: suggestions are computed first (they receive the raw variables, before any preprocessing), then preprocess runs, then Twig renders. The consequence is that a suggestion hook cannot rely on a value that a preprocess function adds later, which is the single most frequent source of confusion.
The Render API behind this flow, including #theme, #cache and bubbling, is described in
Theming overview and the render pipeline and
Render API and cache metadata. Reading Twig files and turning on Twig
debug output is covered in Twig templates and debugging; this page
assumes that debug output is on, because it is the only reliable way to see which suggestions were generated.
Theme hook suggestions
What a suggestion is
A suggestion is a string such as node__exhibition__teaser that is added to a list of candidates for one theme hook. When
Drupal renders the element it converts each candidate name into a file name (each __ becomes --, and the remaining
underscores become hyphens, so the file is node--exhibition--teaser.html.twig) and uses the first
existing template when it walks the list from the last candidate to the first. The base theme hook, node, is the final
fallback, so node.html.twig is used when nothing more specific exists.
Two rules decide almost every problem:
-
A suggestion is written with underscores and begins with the base theme hook followed by two underscores. A suggestion for the
nodehook starts withnode__. A suggestion with a different prefix may never load. Hyphens must not appear in the suggestion; a dynamic value such as a field name or a bundle is converted withstrtr($value, '-', '_')first. -
Order matters. Later entries beat earlier ones. A suggestion alter hook that appends puts its candidate ahead of the ones core added; a hook that inserts at the start of the array loses against core.
Debug output (see the Twig debugging page above) prints the full list as an HTML comment, with x marking the template
actually used and * the files that would have won if they existed. Unlike a file-name guess, that comment shows the real
result after all alter hooks have run, including suggestions added by contributed modules.
Built-in suggestions
Core and contributed modules add many suggestions without any code of yours. The exact lists change with the element, so
read them from the debug comment instead of memorising them. As examples, a node usually offers candidates for the view
mode, the content type, the combination of both and the node ID; a block offers candidates derived from its plugin ID and
from the block entity ID; a field offers candidates derived from field type, field name, entity type and bundle. Page-level
templates are the exception: core does not derive a page template from the content type, so page--exhibition.html.twig
does nothing until a suggestion is added in code.
The three suggestion hooks
Since Drupal 8 suggestions are defined and altered in dedicated hooks, not by editing a theme_hook_suggestions entry in
preprocess as Drupal 7 did. Older tutorials, and any text that reaches back to Drupal 7, still show the preprocess
approach; it does nothing in Drupal 8 and later.
| Hook | Use |
|---|---|
|
Adds suggestions for one theme hook. Implemented by the module that owns the hook, or by any module for its own theme hook. |
|
Alters the suggestions of every theme hook. Receives the suggestions, the variables and the name of the hook. It runs for each rendered element, so keep it cheap and return early. |
|
Alters the suggestions of one theme hook. This is the hook to reach for in nearly every case, both from modules and from themes. |
All three receive the suggestions array by reference. The $variables array they receive is the set of variables that were
passed to the theme hook, before any preprocessing. For a node, $variables['elements'] is the render element and
$variables['elements']['#node'] is the entity; the fields are not yet in content.
A first suggestion: exhibitions with their own page template
The following procedural implementation in THEME.theme adds a page template per content type, the classic example. Core
does not provide this suggestion.
<?php
/**
* @file
* Procedural hooks of the Meridian theme (still valid, see below for the OOP form).
*/
/**
* Implements hook_theme_suggestions_page_alter().
*/
function meridian_theme_suggestions_page_alter(array &$suggestions, array $variables): void {
$node = \Drupal::routeMatch()->getParameter('node');
if ($node instanceof \Drupal\node\NodeInterface) {
$suggestions[] = 'page__' . $node->bundle();
}
}
With this code in place, templates/layout/page--exhibition.html.twig replaces page.html.twig on exhibition nodes. The
cache must be cleared (drush cr) after adding the first template with a new name, because the theme registry caches the
list of template files.
Source: Working With Twig Templates
The route-match lookup above is a static call. It is acceptable inside procedural theme code, but it hides a cache
dependency: the page now varies by route, which the page cache already handles through the route cache context of the page
response. A suggestion that depends on the user, the language or the request query does not get that for free. See
Cache metadata when adding variables below.
Writing the suggestion as an object-oriented hook (Drupal 11.3+)
Starting with Drupal 11.3, a theme can implement hooks as methods of classes, with the same #[Hook] attribute that
modules use. The class lives in src/Hook of the theme and in the namespace Drupal\meridian\Hook. It is registered
automatically as an autowired service, so a .services.yml file is not needed. The hook name is written without the hook_
prefix.
<?php
declare(strict_types=1);
namespace Drupal\meridian\Hook;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\node\NodeInterface;
/**
* Template suggestions of the Meridian theme.
*/
class MeridianSuggestionHooks {
/**
* Gives exhibition pages a dedicated page template.
*
* @param string[] $suggestions
* The suggestions, altered by reference.
* @param array<string, mixed> $variables
* The variables passed to the theme hook, before preprocessing.
*/
#[Hook('theme_suggestions_page_alter')]
public function pageSuggestions(array &$suggestions, array $variables): void {
$node = \Drupal::routeMatch()->getParameter('node');
if ($node instanceof NodeInterface) {
$suggestions[] = 'page__' . $node->bundle();
}
}
/**
* Adds a suggestion for the monospaced accession number field.
*/
#[Hook('theme_suggestions_field_alter')]
public function fieldSuggestions(array &$suggestions, array $variables): void {
$element = $variables['element'] ?? [];
if (($element['#field_name'] ?? '') === 'accession_number') {
$suggestions[] = 'field__accession_number';
}
}
}
The matching file is templates/field/field--accession-number.html.twig. The second method needs no static call: it only
reads the render element. A field name containing a hyphen would have to pass through strtr() first; Drupal field machine
names use underscores, but a route parameter, a view ID or a plugin ID may contain other characters.
Preprocess functions
What preprocess is for
A preprocess function prepares the data for a template. It is the right place to compute a value that is awkward in Twig
(a date difference, a merged attributes object, a class derived from a field), to flatten a nested render array into a
simple variable, or to add a library. It is the wrong place for business rules; see
Keeping business logic out of the theme.
The order in which preprocess runs
Before a template renders, preprocess runs in a fixed sequence. Each step sees what the earlier ones produced:
-
Core default variables, set for every theme hook (the
attributesobject, the current user,is_admin,logged_in,directoryand similar). -
The initial preprocess callback declared on the theme hook definition (the successor to
template_preprocess_HOOKfunctions, see below). -
Module hooks:
MODULE_preprocess()for all modules, thenMODULE_preprocess_HOOK(). -
Theme engine hooks (Twig has none worth knowing).
-
Theme hooks:
THEME_preprocess(), thenTHEME_preprocess_HOOK(). Base themes run before the active theme.
The theme is the last one to speak, which is why a theme can override what a module produced. Because the generic hook
THEME_preprocess() runs for every theme hook on every render, it must be as small as possible.
Source: Theme system overview
Preprocess for a suggestion or for the base hook
The theme registry resolves a suggestion down to a registered theme hook by stripping everything after the last __ until
a registered hook is found. A preprocess function is therefore most reliably written for the base hook, and it decides by
looking at the data. Preprocessing node and branching on the bundle works in every case:
function meridian_preprocess_node(array &$variables): void {
$node = $variables['node'];
if ($node->bundle() === 'exhibition') {
// Exhibition-only variables.
}
}
Source: hook_preprocess_HOOK()
A function named after a suggestion, such as meridian_preprocess_node__exhibition, is only called when that suggestion is
itself registered as a theme hook, which happens for suggestions that have a template file in the theme or a hook
definition. A function name that is correct but is not called is a very common pitfall; if a preprocess function does not
fire, check the base hook first.
Variables available per template
The variables of a template are not documented in one place. Use these sources, from most to least reliable:
-
Twig debug output. With debugging on, the HTML comments name the template and its location. Add
{{ dump(_context|keys) }}temporarily, or{{ kint(_context) }}with the Devel module, to list the variables of the current template. Remove them before committing. -
The docblock at the top of the core template. Core templates list their variables (
node.html.twigdocumentsnode,label,content,date,author_name,url,view_mode,teaser,page,attributesand others). Copy the template into the theme and read the comment. -
The
template_preprocess_HOOKcode, or the initial preprocess callback, of the theme hook. It shows how every variable is derived. -
The theme hook definition.
hook_theme()declares thevariables(defaults) or therender elementthat a theme hook accepts. Variables not declared there can still be added by preprocess.
Common variables worth knowing for the Meridian pages:
| Template | Variables you will most often use |
|---|---|
|
|
|
|
|
|
|
|
|
|
A very common beginner error is to read node.field_x.value in Twig for a value that is a render array in content, or to
read an entity field before it has been loaded. Prefer content.field_x for rendered output (it carries cache metadata and
formatter settings) and node.field_x.value only for raw data that is not displayed as-is.
A first preprocess: days left in an exhibition
The museum wants an "ends in N days" badge on exhibitions. The date arithmetic belongs in preprocess; the markup belongs in Twig. This example is procedural and runs on every supported Drupal version.
<?php
use Drupal\Core\Cache\CacheableMetadata;
/**
* Implements hook_preprocess_node().
*/
function meridian_preprocess_node(array &$variables): void {
/** @var \Drupal\node\NodeInterface $node */
$node = $variables['node'];
if ($node->bundle() !== 'exhibition' || $node->get('field_end_date')->isEmpty()) {
return;
}
$end = $node->get('field_end_date')->date;
$now = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
$seconds_left = max(0, $end->getTimestamp() - $now->getTimestamp());
$variables['days_left'] = intdiv($seconds_left, 86400);
$variables['ending_soon'] = $variables['days_left'] <= 7;
$variables['attributes']['class'][] = $variables['ending_soon'] ? 'is-ending-soon' : 'is-running';
// The value depends on the current time, so the rendered node can only be
// cached until the next day boundary of the countdown.
$variables['#cache']['max-age'] = min(3600, max(1, $seconds_left));
}
Source: hook_preprocess_HOOK()
The Twig side:
{# templates/content/node--exhibition.html.twig #}
{% set classes = ['exhibition', ending_soon ? 'exhibition--ending' : ''] %}
<article{{ attributes.addClass(classes) }}>
{% if ending_soon %}
<p class="exhibition__badge">{{ 'Ends in @count day'|trans({'@count': days_left}) }}</p>
{% endif %}
<h2{{ title_attributes }}>{{ label }}</h2>
{{ content|without('field_end_date') }}
</article>
Source: Working With Twig Templates
The #cache line is what makes this correct. Without it the node would be cached for the page-cache lifetime and show an
outdated number of days; with max-age set, the countdown is recomputed at the latest within the hour. The mechanism is
described in Cache metadata when adding variables.
Object-oriented preprocess hooks
Version map
Object-oriented hooks arrived in stages. The rule to remember is that modules came first and themes later:
| Version | What changed |
|---|---|
11.1 |
Hooks as methods of autowired classes in |
11.2 |
Preprocess hooks ( |
11.3 |
Themes may implement hooks as classes in |
Older books such as Drupal 10 Development Cookbook (2023) show hooks only as functions in a .module file, and Modernizing
Drupal 10 Theme Development (2023) shows preprocess only as functions in a .theme file, often calling \Drupal::
services from inside them. Both remain valid on 11.x, because procedural implementations are still supported, but they are no
longer the recommended style: since 11.1 hooks are classes, preprocess in modules is a class since 11.2, and the theme form is
a class since 11.3. The procedural hooks are expected to survive for some time, but the official guidance does not
describe procedural code as the target.
Module preprocess as a class (11.2+)
A module may implement hook_preprocess_HOOK as a method. The meridian_collection module adds the number of objects on
display to every exhibition node, using a real injected service instead of a static call. The container resolves the
constructor arguments because hook classes are autowired.
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Hook;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\meridian_collection\CollectionCounterInterface;
/**
* Preprocess hooks of the collection module.
*/
class CollectionPreprocessHooks {
public function __construct(
protected CollectionCounterInterface $counter,
) {}
/**
* Adds the number of objects on display to exhibition nodes.
*/
#[Hook('preprocess_node')]
public function preprocessNode(array &$variables): void {
$node = $variables['node'];
if ($node->bundle() !== 'exhibition') {
return;
}
$variables['objects_on_display'] = $this->counter->countOnDisplay();
// The count changes whenever a collection_object is saved.
CacheableMetadata::createFromRenderArray($variables)
->addCacheTags(['collection_object_list'])
->applyTo($variables);
}
}
The hook is limited to one implementation of preprocess_node per module, because the attribute #[Hook('preprocess_node')]
binds the module’s single preprocess. A module that needs several preprocess steps for the same theme hook calls them from
that one method. The service lives in meridian_collection.services.yml in the usual way (see
Services and dependency injection), and the list tag
collection_object_list is the list cache tag of the custom entity type
(see Custom entity types).
Theme preprocess as a class (11.3+)
The same days-left logic can live in the theme as a class. There is no constructor injection concern here, because the logic
stays small and static. Note the declare(strict_types=1), the typed by-reference array and the void return.
<?php
declare(strict_types=1);
namespace Drupal\meridian\Hook;
use Drupal\Core\Hook\Attribute\Hook;
/**
* Preprocess hooks of the Meridian theme.
*/
class MeridianPreprocessHooks {
#[Hook('preprocess_node')]
public function preprocessNode(array &$variables): void {
$node = $variables['node'];
if ($node->bundle() !== 'exhibition' || $node->get('field_end_date')->isEmpty()) {
return;
}
$end = $node->get('field_end_date')->date;
$seconds_left = max(0, $end->getTimestamp() - time());
$variables['days_left'] = intdiv($seconds_left, 86400);
$variables['ending_soon'] = $variables['days_left'] <= 7;
$variables['#cache']['max-age'] = min(3600, max(1, $seconds_left));
}
#[Hook('preprocess_html')]
public function preprocessHtml(array &$variables): void {
$variables['attributes']['class'][] = 'meridian';
}
}
The theme’s meridian.info.yml should require the version that introduced the feature, so that Composer and the installer
refuse to install it on an older core:
name: Meridian
type: theme
description: 'Theme of the Meridian Museum site.'
package: Meridian
core_version_requirement: ^11.3
base theme: false
What a theme class cannot do
Themes get a subset of the module feature set. The rules are deliberate; breaking them silently loses the hook rather than raising an error:
-
The
#[Hook]attribute in a theme does not accept theorderormoduleparameters. -
#[ReorderHook]and#[RemoveHook]are not supported in themes. The execution order cannot be changed: a base theme’s hooks run before those of the active theme, and hooks of an inactive theme do not run. -
A theme cannot implement a hook on behalf of another theme, and a module cannot implement a hook on behalf of a theme.
-
Theme hook classes do not change module hooks. A module’s own preprocess and its
hook_themestay as they were. -
Only a subset of the alter hooks is available to themes. The supported set includes
css_alter,js_alter,js_settings_alter,library_info_alter,form_alterand itsform_FORM_ID_altervariants,element_info_alter,page_attachments_alter,theme_registry_alterand the theme suggestion alters. A hook outside the set must stay inTHEME.themeor in a module. -
If the theme splits procedural hooks into several files, those files must use the
.themeor.incextension so they are found.
Supporting several Drupal versions: the LegacyHook shim
A reusable theme or a contributed module that must run on 10.x or on 11.0/11.1 keeps a procedural function and adds a thin
class for newer versions. Both would then run on a new core, so the procedural function carries #[LegacyHook], which makes
Drupal skip it when the class form is supported:
use Drupal\Core\Hook\Attribute\LegacyHook;
use Drupal\meridian\Hook\MeridianPreprocessHooks;
// @phpstan-ignore-next-line
#[LegacyHook]
function meridian_preprocess_node(array &$variables): void {
\Drupal::service(MeridianPreprocessHooks::class)->preprocessNode($variables);
}
The @phpstan-ignore-next-line is needed because older cores do not define the attribute class. For modules, the class
must be registered by hand in *.services.yml with autowire: true when it has to work on 10.1 to 11.0, because the
automatic registration arrived with the OOP hook support of 11.1. For a theme that only targets 11.3 or later there is
no need for any of this, which is another reason to set core_version_requirement: ^11.3.
Hooks that remain procedural
A few hooks cannot be classes, and an attribute on them is ignored. They are the install and update family:
hook_install, hook_uninstall, hook_schema, hook_requirements, hook_update_N, hook_post_update_NAME and a handful of
others. Conditionally defined hook implementations (a function wrapped in an if) and procedural hooks defined in src/ are
not supported either. Theme hooks follow the version map above. The authoritative list evolves from release to release, so it
should be re-read in the change record before a large conversion. The wider subject is covered in
Hooks and OOP hooks.
Deprecated: template_preprocess_HOOK and the file keys
From 11.3 the old convention of a function named template_preprocess_HOOK that hook_theme() locates through the file or
includes keys is deprecated. The replacement is an initial preprocess callback declared on the theme hook, which accepts
a service:method form resolved through the callable resolver, so the code can live in an autowired class. A custom theme
hook of the museum, such as an opening-hours widget defined in hook_theme(), is converted by removing the file keys and
adding the callback key. This affects module developers who define their own theme hooks far more than themers.
#[Hook('theme')]
public function theme(): array {
return [
'meridian_opening_hours' => [
'variables' => ['hours' => [], 'closed_message' => NULL],
'initial preprocess' => OpeningHoursPreprocess::class . ':preprocess',
],
];
}
Source: Theme system overview
Suggestions from a module
A module can influence template choice for any theme, and because a theme can override it by adding a more specific
suggestion afterwards, a module should add few suggestions and name them clearly. The meridian_collection module adds a
suggestion for blocks it provides, so a theme can template every "On display now" block without matching the block entity ID
of each placement:
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Hook;
use Drupal\Core\Hook\Attribute\Hook;
/**
* Template suggestions provided by the collection module.
*/
class CollectionSuggestionHooks {
/**
* Adds a suggestion based on the block plugin.
*/
#[Hook('theme_suggestions_block_alter')]
public function blockSuggestions(array &$suggestions, array $variables): void {
if (($variables['elements']['#plugin_id'] ?? '') === 'meridian_collection_on_display') {
$suggestions[] = 'block__meridian_collection_on_display';
}
}
}
The matching template is block--meridian-collection-on-display.html.twig. If the module also ships its own template for
the block, it has to be registered in hook_theme() so the module’s file is found in the module directory. A theme that wants to override it copies the file into
its own templates directory. Both need the cache cleared the first time, since template files are discovered when the
theme registry is built.
Cache metadata when adding variables
Why it matters
Every value you put into $variables becomes part of the rendered output, and the rendered output is cached. Drupal knows
about the dependencies of the entity being rendered and of the fields rendered from it, but it does not know about the
dependencies of a value you computed yourself. A preprocess function that reads the current user, the current time, another
entity, a configuration object, a setting or a request parameter has introduced a dependency that the cache does not know
about. The page then goes stale or, worse, a variation meant for one user is served to another. Anonymous traffic hides the
bug during development, and it appears in production.
How preprocess communicates metadata
The theme manager reads #attached and #cache from the preprocess variables after the preprocess chain has run, and
passes them to the renderer as if they were properties of the element being rendered. This is documented in the core source
as the officially supported way to attach bubbleable metadata from preprocess functions. Two details to keep:
-
Metadata in
$variables['#cache']is bubbled to the parents of the element, so it reaches the page response. -
keysis removed from$variables['#cache']. Preprocess functions cannot create their own cacheable element; they can only add contexts, tags and max-age to the element being rendered.
Metadata attached to a child render array only bubbles when the template outputs that child. A render array that is
passed to the template but never printed contributes nothing, so a value that is computed and then hidden by Twig is not a
cache dependency (and a value that is printed by a path that skips content may need its metadata merged by hand).
use Drupal\Core\Cache\CacheableMetadata;
function meridian_preprocess_node(array &$variables): void {
$node = $variables['node'];
if ($node->bundle() !== 'exhibition') {
return;
}
$metadata = CacheableMetadata::createFromRenderArray($variables);
// 1. Another entity: the gallery term shown as a label.
$gallery = $node->get('field_gallery')->entity;
if ($gallery) {
$variables['gallery_label'] = $gallery->label();
$metadata->addCacheableDependency($gallery);
}
// 2. The user: curators see a link to the notes.
$variables['can_view_notes'] = \Drupal::currentUser()->hasPermission('access curator notes');
$metadata->addCacheContexts(['user.permissions']);
// 3. Configuration: a banner message edited in the settings form.
$config = \Drupal::config('meridian_collection.settings');
$variables['banner'] = $config->get('banner');
$metadata->addCacheableDependency($config);
$metadata->applyTo($variables);
}
The three cases illustrate the three kinds of dependency: entities and config objects contribute their cache tags (use
addCacheableDependency(), which adds the tags, contexts and max-age of anything cacheable); request or user state
contributes a cache context (user.permissions, user, languages:language_content, url.query_args:key); time
contributes a max-age. Never invent a max-age of 0 to be safe, because it disables caching for the whole response through
bubbling; compute the real validity or use a context.
CacheableMetadata::createFromRenderArray() followed by applyTo() merges with the metadata already present in
$variables['#cache'], instead of overwriting the node’s own tags, which a plain assignment would do. Assigning a whole new tags array to the #cache entry
directly is only correct when you add to a key that nothing else wrote.
Source: Cacheability of render arrays
Suggestion hooks and caching
Suggestion hooks run before preprocess and are not a documented place to attach metadata to the element. A suggestion should therefore depend only on data whose changes already invalidate the element: the entity being rendered (its own cache tags), its bundle and view mode, and a field value on that entity. A suggestion that depends on the user, the time or the request breaks the cache silently, because the first rendering of the element decides the template for everybody that gets the cached copy. The safe pattern is to keep the suggestion on entity data and to put any per-user or per-time branching in Twig, with the cache metadata added in preprocess as shown above.
The hook_theme_suggestions_alter() variant, which runs for every theme hook, deserves a particular warning. It is
called on each render, so a query or a service call inside it multiplies across the page, and a mistake in its guard clause
affects every template of the site.
Keeping business logic out of the theme
A theme is replaceable presentation. The site owner can switch the default theme, a preview of a new theme can render the same content, and the same entity can be rendered by JSON:API or by Layout Builder without any theme. Everything that decides what the data is, as opposed to how it looks, belongs in a module that can be tested without rendering a page.
Signs that code has moved into the wrong place:
-
the preprocess function queries the database or an entity query;
-
it calls an external service, reads or writes configuration, state or session data, or sends a message;
-
it decides who may see something by reading roles or user IDs rather than checking a permission;
-
it contains an
ifthat encodes an editorial rule (a gallery is "free after 4 pm"); -
removing the theme would make a feature disappear.
The Meridian site illustrates the separation. The countdown formatting (days_left, the class on the node) is presentation
and sits in the meridian theme. The number of objects on display comes from the meridian_collection.collection_counter
service. It is computed and cached in the module, and handed to templates through the module’s own preprocess. The test for
the counter is a Kernel test of the module; the theme test, if any, only checks the markup. Both preprocess classes above
follow the rule: the theme code reads the entity and the clock, the module code injects a service.
Other places to put logic that does not belong in preprocess: a field formatter or plugin for per-field display, a Twig extension for a reusable filter (see Twig extensions in modules), a Single Directory Component for a reusable piece of markup with its own props (Single Directory Components), a block plugin for a region widget, or a Views field for a listing.
Static \Drupal:: calls in classes are the opposite smell: in a class, inject the service. The static call is acceptable only
in a procedural .theme hook, where no constructor exists. Older theming books show such calls in .theme code as normal
practice; in a theme hook class of 11.3 the same code is better with the service obtained from a module and passed through
a variable.
Security
-
Escaping. Twig autoescapes everything printed with
{{ }}. A variable set in preprocess is escaped like any other unless it is a render array or aMarkupobject. Do not build HTML strings in preprocess and mark them safe. If a value comes from a user (a node title, a field value), print it as text and let Twig escape it; the|rawfilter on user input is a cross-site scripting risk. UseXss::filter()orHtml::escape()if a string with limited HTML must really reach the template, and prefer a text format with the field’s formatter. -
Access. Preprocess runs after access to the entity has been checked in most routes, but it can run for entities in a listing or inside another entity. A variable that exposes a field ignoring access, such as
$node->get('field_x')->valuecopied into the template, bypasses field-level access. Prefercontent.field_x, which is built through the formatter and checks access. If a raw value is needed, callaccess('view')on the field items first. Never decide who sees a link by checking a role name; check a permission and add theuser.permissionscontext. -
Information disclosure. Debugging helpers (
dump,kint) and Twig debug comments reveal file paths and variables. They belong in development only (see the Twig debugging page). A leftoverdump()on a production page can print private fields. -
Private data in cache. A value that varies by user and is added without the matching context may be served to the next visitor. This is the most serious consequence of missing cache metadata.
Common errors and pitfalls
| Symptom | Likely cause and fix |
|---|---|
A new |
The theme registry is cached. Run |
A custom suggestion never loads |
It does not start with the base hook and two underscores, it uses hyphens, or it was inserted at the start of the array. Append instead. |
The suggestion exists but the variable added in preprocess is empty in the condition |
Suggestion hooks run before preprocess. Test entity data in the suggestion, not a preprocess variable. |
A preprocess function for a suggestion does not run |
Only the registered hook runs it. Preprocess the base hook and branch on the data, or add the template file so the suggestion is registered. |
OOP hook method is not called in a theme |
The class is not in |
The hook runs twice |
A procedural function and a class both implement it, and the procedural one has no |
|
Not supported in themes. Base themes always run first. |
Anonymous users see a link meant for curators |
The user dependency was added without the |
A countdown is stale |
The time dependency has no max-age. Compute the max-age in preprocess, never |
The whole site becomes slow after adding a |
It runs for every theme hook. Check the hook name first and return early; avoid queries. |
A variable disappears in Twig |
Another preprocess (a later theme or module) overwrote the key. The theme runs last, so check base themes and the module
list. Use a unique name such as |
Twig shows a PHP array as |
The variable is a nested array that is not a render array. Pick a key in Twig or flatten it in preprocess. |
Older books and tutorials
Tutorials and books written for Drupal 7, 8 or early 9 say that suggestions are added in preprocess by appending to
$variables['theme_hook_suggestions']. That key does not exist in Drupal 8 and later; use the suggestion hooks. Modernizing
Drupal 10 Theme Development (2023) otherwise teaches the correct suggestion and preprocess concepts, but it was written
before Drupal 11.2 and 11.3: it shows preprocess only as procedural .theme functions and calls \Drupal:: services from
them, while the current style is a class in src/Hook with a theme that requires 11.3 or later. Drupal 10 Development
Cookbook (2023) likewise knows hooks only as functions in .module; since 11.1 the implementations in modules can be
classes, and since 11.2 so can preprocess hooks. The supporting information on these corrections is gathered in
Where older books and tutorials are outdated.
Checking the work
A short checklist when a template or variable does not appear:
ddev drush cr
ddev drush php:eval "var_dump(\Drupal::theme()->getActiveTheme()->getName());"
On a development site, enable Twig debugging in sites/development.services.yml as described in the Twig debugging page, reload
the page, and read the comment above the markup. Then, in order: confirm the template file name against the printed
candidates, confirm the hook is registered (for modules, drush php:eval can call the module handler), and check the page response headers X-Drupal-Cache-Tags and X-Drupal-Cache-Contexts (visible when the
http.response.debug_cacheability_headers container parameter is enabled) to see whether the expected tag or context reached
the response. The wider performance behaviour of those headers is in
Performance and caching.
Preprocess and suggestion code is also easy to cover with a Kernel test that renders a node and asserts on the output; see Automated testing.
Source: Working With Twig Templates
Where this fits in the theme
Template files and their directories are described in Theme info and regions, the assets a preprocess may attach in Libraries and assets, reusable components in Single Directory Components, and accessibility concerns for the output you produce in Accessibility and performance in themes. Cache vocabulary is shared with Render API and cache metadata.