hook_theme() Explained: Registering Custom Theme Hooks in Drupalfor Drupal 11 , and 10

Last updated :  

In the last lesson you saw the #theme property point at a named hook — item_list — and watched Drupal turn that into HTML without your code ever touching a template file directly. This lesson answers the question that probably left hanging: where do hook names like item_list actually come from, and how does a module tell Drupal "I have a template, here's its name, here's what data it expects"? The answer is one function: hook_theme().

What you'll learn in this lesson

  • Why #theme would fail with a fatal error if a hook were never registered
  • The two different ways a module can describe what data a theme hook receives
  • How Drupal turns a hook name into an actual template filename — and why that convention is what makes themes able to override module output
  • How preprocess functions let you tweak the variables going into a template without ever touching the template file

Why does #theme need a registry at all?

Think back to the previous lesson: '#theme' => 'item_list' is just a string. For Drupal to turn that string into the right Twig file and know which variables to pass it, something has to have told Drupal, in advance, "the hook named item_list exists, and here's its data contract." That registration step is hook_theme() — every theme hook you'll ever use, core or custom, gets into Drupal's theme registry this way. Skip it, and using '#theme' => 'my_custom_hook' in a render array causes a fatal error, because as far as Drupal is concerned, that hook simply doesn't exist.

The source file

Path: modules/render_example/render_example.module

<?php

/**
 * @file
 * Demonstrates using Drupal's Render API.
 */

/**
 * @defgroup render_example Example: Render API
 * @ingroup examples
 * @{
 * The  @link https://www.drupal.org/docs/8/api/render-api Render API @endlink
 * consists of two parts: one, structured arrays that provide data, and hints
 * about how that data should be rendered, and two, a rendering pipeline that
 * can be used to render these arrays into various output formats. This example
 * module looks at how to define content using render arrays, as well as how to
 * use alter hooks to manipulate render arrays created by other modules.
 *
 * For more on the rendering pipeline see @link
 * https://www.drupal.org/docs/8/api/render-api/the-drupal-8-render-pipeline The
 * Drupal 8 Render Pipeline @endlink.
 *
 * In order to ensure that a theme can completely customize the markup output
 * by Drupal, module developers should avoid directly writing HTML markup for
 * pages, blocks, and other user-visible output in their modules, and should
 * instead return structured "render arrays". Checkout the example code in
 * \Drupal\render_example\Controller\RenderExampleController::arrays() for an
 * explanation of how to define new renderable arrays. The output from that
 * code can be viewed at examples/render_example/arrays.
 *
 * One of the primary benefits of using arrays to define content instead of
 * strings of HTML is that arrays are easier to manipulate. There are dozens of
 * hooks, and other ways to gain access to and manipulate existing render arrays
 * during the rendering process both from within a module, and via a theme. As
 * a rule of thumb the process of rendering an array into HTML is delayed for as
 * long as possible. In most cases it's not until the variable containing the
 * content to be rendered is printed out in a Twig template file that it is
 * finally rendered.
 *
 * For examples of altering render arrays checkout the code in
 * render_example_preprocess_page() and render_example_preprocess_block(). There
 * is a form at examples/render_example/altering that can be used to turn these
 * features on and off if you would like to see the results of the array
 * altering code on your site.
 *
 * This module contains code that can display the render array used to build
 * each page, and/or block, as you navigate through a site as a way to show some
 * examples of real render arrays being used. This functionality requires that
 * the @link https://www.drupal.org/project/devel Devel module @endlink be
 * installed in order to work.
 *
 * Modules can also provide new render element types. A powerful way to
 * encapsulate complex display logic into a reusable widget. This can help to
 * cut down on code repetition, and allow other module developers to build off
 * of your work. See an example of a new render element definition by looking at
 * \Drupal\render_example\Element\Marquee.
 *
 * Forms are generated using a superset of the Render API. You can see examples
 * of how the Render API is used when creating forms in the fapi_example module.
 *
 * @see theme_render
 * @see \Drupal\Core\Render\RendererInterface::render()
 * @see \Drupal\Core\Template\TwigExtension::renderVar()
 */

use Drupal\Core\Render\Element;

/**
 * Implements hook_theme().
 */
function render_example_theme() {
  return [
    // These theme hooks are both used by examples in
    // \Drupal\render_example\Controller\RenderExampleController::arrays().
    'render_example_add_div' => ['render element' => 'element'],
    'render_array' => ['render element' => 'element'],
    // This is used in combination with \Drupal\render_example\Element\Marquee
    // to define a new custom render element type that allows for the use of
    // '#type' => 'marquee' elements in a render array.
    'render_example_marquee' => [
      'variables' => [
        'content' => '',
        'attributes' => [],
      ],
    ],
  ];
}

/**
 * Implements hook_preprocess_page().
 *
 * Demonstrates using a preprocess function to alter the renderable array that
 * represents the page currently being viewed.
 */
function render_example_preprocess_page(&$variables) {
  // Only modify the 'altering' page.
  if (\Drupal::routeMatch()->getRouteName() !== 'render_example.altering') {
    return;
  }

  $config = \Drupal::config('render_example.settings');

  // Preprocess hooks are invoked by the theme layer, and are used to give
  // modules a chance to manipulate the variables that are going to be made
  // available to a specific template file. Since content is still defined as
  // renderable arrays at this point you can do quite a bit to manipulate the
  // eventual output by altering these arrays.
  //
  // The $page variable in this case contains the complete content of the page
  // including all regions, and the blocks placed within each region.
  //
  // The actual process of converting a renderable array to HTML is started when
  // this variable is printed out within a Twig template. Drupal's Twig
  // extension provides a wrapper around the Twig code that prints out variables
  // which checks to see if the variable being printed is a renderable array and
  // passes it through \Drupal\Core\Render\RendererInterface::render() before
  // printing it to the screen.
  $page = &$variables['page'];

  // Move the breadcrumbs into the content area.
  if ($config->get('move_breadcrumbs') && !empty($page['breadcrumb']) && !empty($page['content'])) {
    $page['content']['breadcrumb'] = $page['breadcrumb'];
    unset($page['breadcrumb']);
    $page['content']['breadcrumb']['#weight'] = -99999;

    // Force the content to be re-sorted.
    $page['content']['#sorted'] = FALSE;
  }

  // Re-sort the contents of the sidebar in reverse order.
  if ($config->get('reverse_sidebar') && !empty($page['sidebar_first'])) {
    $page['sidebar_first'] = array_reverse($page['sidebar_first']);
    foreach (Element::children($page['sidebar_first']) as $element) {
      // Reverse the weights if they exist.
      if (!empty($page['sidebar_first'][$element]['#weight'])) {
        $page['sidebar_first'][$element]['#weight'] *= -1;
      }
    }
    // This forces the sidebar to be re-sorted.
    $page['sidebar_first']['#sorted'] = FALSE;
  }

  // Show the render array used to build the current page.
  // This relies on the Devel module's variable dumper service.
  // https://wwww.drupal.org/project/devel
  if (Drupal::moduleHandler()->moduleExists('devel') && $config->get('show_page')) {
    $page['content']['page_render_array'] = [
      '#type' => 'markup',
      '#prefix' => '<h2>' . t('The page render array') . '</h2>',
      // The devel.dumper service is provided by the devel module and makes for
      // and easier to read var_dump(). Especially if the companion Kint module
      // is enabled.
      'dump' => \Drupal::service('devel.dumper')->exportAsRenderable($page, '$page'),
      '#weight' => -99999,
    ];

    $page['content']['#sorted'] = FALSE;
  }
}

/**
 * Implements hook_preprocess_block().
 */
function render_example_preprocess_block(&$variables) {
  // Only modify the 'altering' page.
  if (\Drupal::routeMatch()->getRouteName() !== 'render_example.altering') {
    return;
  }

  $config = \Drupal::config('render_example.settings');

  // This example shows how you can manipulate an existing renderable array. In
  // this case by adding #prefix and #suffix properties to the block in order to
  // wrap a <div> around it.
  if ($config->get('wrap_blocks')) {
    $variables['content']['#prefix'] = '<div class="block-prefix"><p>' . t('Prefixed') . '</p>';
    $variables['content']['#suffix'] = '<span class="block-suffix">' . t('Block suffix') . '</span></div>';
  }

  // Show the render array used to build each block if the Devel module is
  // installed and the feature is enabled.
  if (Drupal::moduleHandler()->moduleExists('devel') && $config->get('show_block')) {
    $variables['content']['block_render_array'] = [
      '#type' => 'markup',
      '#prefix' => '<h2>' . t('The block render array for @block_id.', ['@block_id' => $variables['plugin_id']]) . '</h2>',
      'dump' => \Drupal::service('devel.dumper')->exportAsRenderable($variables, $variables['plugin_id']),
    ];
  }
}

/**
 * @} End of "defgroup render_example".
 */

How it works

The naming convention Drupal relies on

Drupal finds this function purely by name: {module_name}_theme(). There's no separate configuration step, no file to register anywhere — Drupal scans every enabled module for a function matching that pattern when it builds the theme registry, and calls whatever it finds. The return value is a keyed array: each key is a theme hook name, and its value describes the data contract for that hook.

Two flavors of theme hook definition

This module registers three hooks, and they split into two categories depending on what kind of data the template actually receives.

Render element hooks — pass the whole array

'render_example_add_div' => ['render element' => 'element'],
'render_array'           => ['render element' => 'element'],

The 'render element' key says: this hook receives one complete render array, made available to the Twig template under the variable name given (element, in both cases here). Reach for this when your template's job is to wrap or decorate an existing tree of already-built content rather than work with a handful of discrete named values — exactly what render_example_add_div does when it wraps children in a <div>.

Variables hooks — pass named, defaulted values

'render_example_marquee' => [
  'variables' => [
    'content'    => '',
    'attributes' => [],
  ],
],

The 'variables' key instead declares an explicit list of named variables and their default values. Drupal merges these defaults with whatever the caller actually supplies, so the template can always safely reference {{ content }} and {{ attributes }} — even if nobody passed a value, the empty-string and empty-array defaults keep the template from breaking. This is the shape to reach for whenever you have a handful of discrete, purpose-built pieces of data to hand to a template — which, in practice, covers most custom theme hooks you'll write.

How a hook name becomes a filename

Drupal derives the template's filename mechanically: underscores become hyphens, and .html.twig gets appended. So render_example_marquee maps to render-example-marquee.html.twig, and render_array maps to render-array.html.twig. These files live in the module's templates/ directory by default — but this is exactly the mechanism that lets a theme override a module's markup: any active theme can supply its own file with that exact name in its own templates/ folder, and Drupal will use the theme's version instead. This is the entire reason theme hooks exist rather than modules just writing HTML strings — it's what keeps presentation genuinely separable from logic.

How a custom render element connects back to its hook

The render_example_marquee hook is tightly coupled to the marquee render element you saw in the last lesson. That element's getInfo() method sets '#theme' => 'render_example_marquee' as a default. So the full chain looks like: a render array declares '#type' => 'marquee' → Drupal resolves the Marquee element plugin → the plugin's defaults supply #theme → Drupal looks up render_example_marquee in the registry that hook_theme() built → the matching Twig template renders the final HTML. Each piece — element plugin, hook registration, template file — does exactly one job, and hook_theme() is the glue connecting the middle two.

Preprocess functions — adjusting variables without touching the template

function render_example_preprocess_page(&$variables) {
  if (\Drupal::routeMatch()->getRouteName() !== 'render_example.altering') {
    return;
  }
  ...
}

Preprocess functions are the complementary half of the theme system. Named {module}_preprocess_{hook}(), they're called automatically right before a template renders, and receive the variables array by reference — meaning you can add, remove, or mutate variables without ever opening the template file. render_example_preprocess_page() demonstrates three real mutations, all still working on $variables['page'] as a plain nested render array: moving the breadcrumb block into the content region, reversing the sidebar's child order and weights, and (if the Devel module is present) injecting a debug dump of the whole page array so you can literally see the structure you're working with.

Notice $page['content']['#sorted'] = FALSE; after each mutation — this tells Drupal's renderer that the children of this region were changed and their weights need to be re-evaluated before output, rather than trusting a stale sort order.

Guarding a preprocess function to one route

if (\Drupal::routeMatch()->getRouteName() !== 'render_example.altering') {
  return;
}

render_example_preprocess_page() fires on every single page load across the whole site — that's how preprocess hooks work, they're global by default. Without this guard, the breadcrumb-moving and sidebar-reversing logic would silently apply everywhere. Checking \Drupal::routeMatch()->getRouteName() against the specific route you care about, right at the top of the function, is the standard way to scope a preprocess hook down to just the page(s) it's meant for.

Config-gated behavior: notice both preprocess functions read from \Drupal::config('render_example.settings') before doing anything. Rather than hardcoding "always move the breadcrumbs," the module exposes each behavior as a togglable setting. This is worth copying in your own modules — it lets a site builder turn features on and off without ever touching your PHP.

See it for yourself

Visit /examples/render-example/altering on your DDEV site. This page's tables are rendered through the render_array and render_example_add_div theme hooks that hook_theme() registers, invoked from the module's own hook_preprocess_page() and hook_block_view_alter() implementations.

The render_example module's Alter pages and blocks page showing tables rendered through custom theme hooks

Quick check: you're writing a theme hook where the template needs three specific named pieces of data with sensible defaults if the caller forgets one. Do you use 'render element' or 'variables'? If you said 'variables', you've got it — that's exactly the shape it's built for.

Key takeaways

  • hook_theme() must be named {module_name}_theme() and returns a keyed array of theme hook definitions — this is how Drupal discovers which templates your module provides, and using an unregistered #theme value is a fatal error.
  • Use 'render element' => 'variable_name' when a template receives one complete render array to wrap or decorate; use 'variables' => [...] when it receives discrete named values with explicit defaults.
  • Drupal maps a hook name to a template filename by converting underscores to hyphens and appending .html.twig — and any active theme can override your module's template by placing a same-named file in its own templates/ directory.
  • A custom render element plugin wires itself to a theme hook by setting '#theme' inside its getInfo() method, turning a whole render-plus-template pipeline into a single reusable #type.
  • Preprocess functions, named {module}_preprocess_{hook}(), receive template variables by reference just before rendering — the correct place for conditional markup changes, without ever touching the template file itself.
  • Always guard a preprocess function meant for one page with an early route check (\Drupal::routeMatch()->getRouteName()) — preprocess hooks run globally by default, and skipping the guard means unintended site-wide side effects.

Coming up next

You now know how a render array becomes HTML through #theme, and how a module registers the templates that make that possible. The final Render API lesson zooms out to trace the complete pipeline end to end — from a controller returning a raw array, through every property that shapes it, to the finished HTML string — tying together everything from this topic in one place.