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:

  1. 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.

  2. 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 $variables array. 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.

flowchart TD A[Render array with #theme node] --> B[Theme registry lookup] B --> C[hook_theme_suggestions_node] C --> D[hook_theme_suggestions_alter] D --> E[hook_theme_suggestions_node_alter] E --> F[Candidates, last suggestion wins] F --> G[Variables prepared] G --> H[Preprocess chain] H --> I[Twig template renders] H -. metadata in #cache and #attached .-> J[Bubbled to the parent render array]

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 node hook starts with node__. 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 with strtr($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

hook_theme_suggestions_HOOK()

Adds suggestions for one theme hook. Implemented by the module that owns the hook, or by any module for its own theme hook.

hook_theme_suggestions_alter()

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.

hook_theme_suggestions_HOOK_alter()

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.

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:

  1. Core default variables, set for every theme hook (the attributes object, the current user, is_admin, logged_in, directory and similar).

  2. The initial preprocess callback declared on the theme hook definition (the successor to template_preprocess_HOOK functions, see below).

  3. Module hooks: MODULE_preprocess() for all modules, then MODULE_preprocess_HOOK().

  4. Theme engine hooks (Twig has none worth knowing).

  5. Theme hooks: THEME_preprocess(), then THEME_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.

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.
  }
}

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.twig documents node, label, content, date, author_name, url, view_mode, teaser, page, attributes and others). Copy the template into the theme and read the comment.

  • The template_preprocess_HOOK code, or the initial preprocess callback, of the theme hook. It shows how every variable is derived.

  • The theme hook definition. hook_theme() declares the variables (defaults) or the render element that 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

node

node (the entity), label, content (renderable fields), url, view_mode, teaser, page, date, author_name, attributes, title_attributes

field

element, items, label, label_hidden, multiple, field_name, field_type, entity_type, bundle, attributes

block

plugin_id, configuration, content, label, attributes, title_attributes

page

page (regions), node when on a node route, logged_in, is_admin, base_path, front_page

html

page, head_title, attributes, html_attributes, root_path, db_is_active, logged_in

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));
}

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>

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 src/Hook, marked with #[Hook('name')]. #[LegacyHook] keeps a procedural shim for older versions.

11.2

Preprocess hooks (hook_preprocess and hook_preprocess_HOOK) in modules may be classes. Ordering (order parameter), #[ReorderHook] and #[RemoveHook] replace hook_module_implements_alter. Themes remain procedural.

11.3

Themes may implement hooks as classes in src/Hook, including preprocess, hook_theme, a set of alter hooks and the theme suggestion alters. template_preprocess_HOOK functions and the file/includes keys of hook_theme() are deprecated in favour of an initial preprocess callback in a class.

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 the order or module parameters.

  • #[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_theme stay 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_alter and its form_FORM_ID_alter variants, element_info_alter, page_attachments_alter, theme_registry_alter and the theme suggestion alters. A hook outside the set must stay in THEME.theme or in a module.

  • If the theme splits procedural hooks into several files, those files must use the .theme or .inc extension 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',
    ],
  ];
}

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.

  • keys is 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.

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.

flowchart LR A[Suggestion hook] -->|reads entity data only| B[Template chosen] C[Preprocess] -->|adds variable| D[Value in template] C -->|adds #cache contexts, tags, max-age| E[Render cache entry] D --> E E --> F[Bubbles to page response]

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 if that 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 a Markup object. 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 |raw filter on user input is a cross-site scripting risk. Use Xss::filter() or Html::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')->value copied into the template, bypasses field-level access. Prefer content.field_x, which is built through the formatter and checks access. If a raw value is needed, call access('view') on the field items first. Never decide who sees a link by checking a role name; check a permission and add the user.permissions context.

  • 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 leftover dump() 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 node--exhibition.html.twig is ignored

The theme registry is cached. Run drush cr. Check the debug comment for the file name Drupal actually looked for.

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 src/Hook, the namespace is not Drupal\THEME\Hook, the attribute lacks the method name or class __invoke, the hook is not in the supported set for themes, the theme is not the active one or a base of the active one, or core_version_requirement allows a core older than 11.3. Rebuild the cache.

The hook runs twice

A procedural function and a class both implement it, and the procedural one has no #[LegacyHook].

order or #[ReorderHook] in a theme has no effect

Not supported in themes. Base themes always run first.

Anonymous users see a link meant for curators

The user dependency was added without the user.permissions context (or user). Add it in preprocess.

A countdown is stale

The time dependency has no max-age. Compute the max-age in preprocess, never 0.

The whole site becomes slow after adding a theme_suggestions_alter

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 days_left.

Twig shows a PHP array as Array

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.

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.