In the last lesson we saw the simplest possible theme hook: one render array, one template, no branching. Real Drupal sites are rarely that tidy. A theme developer usually wants to override just this one content type, or just this specific page, without touching every other place the same theme hook fires. That's exactly what this lesson covers — plus the companion mechanism, preprocess functions, that lets a module quietly prepare data before it ever reaches Twig.
What you'll learn in this lesson
- The two ways to register a theme hook —
render elementvs.variables— and when to use each - How Drupal derives a template's filename, and how to override it with the
templatekey - What "template suggestions" are and how a theme can override output at increasing levels of specificity
- How
hook_preprocess_HOOK()lets you transform variables right before they reach a template — cleanly, without any logic in the Twig file itself - A real pattern for coordinating
hook_form_alter()and a preprocess function together
theming_example.module, the module-level file that actually registers everything the previous lesson's template depended on. Think of it as pulling back the curtain on where element in that template actually came from.The source file
Path: modules/theming_example/theming_example.module
<?php
/**
* @file
* Explains how a module declares theme functions, preprocess functions, and
* templates.
*
* The underlying approach is that a module should allow themes to do all
* rendering, but provide default implementations where appropriate.
*
* Modules are also expected to leave data as render arrays as long as possible,
* leaving rendering to theme functions and templates.
*/
use Drupal\Core\Form\FormStateInterface;
/**
* @defgroup theming_example Example: Theming
* @ingroup examples
* @{
* Example of Drupal theming.
*
* The Theming Example module attempts to show how module developers can add
* theme functions to their projects so that themes can modify output.
*
* Module developers should strive to avoid hard-coding any HTML into the
* output of their code. This should all be done in theme functions.
*
* Starting with the first example, theming_example_page(): The output is put
* into an array $content, which is then fed to
* theme_theming_example_content_array(), which loops over the content, wrapping
* it in HTML markup in the process.
*
* In order to get theme_theming_example_content_array() recognized, it needs to
* be registered in a hook_theme() implementation, theming_example_theme() in
* this case.
*
* theming_example_list_page() and theming_example_order_form() work in the same
* way.
*
* In theming-example-list.html.twig, the content is themed as an ordered
* list and given the theming-example-list class attribute, which is defined in
* theming_example.css
*
* The fourth example shows the use of theming_example_text_form.tpl.php.
* This file can be copied to a theme's folder, and it will be used instead.
*
* This example also shows what can be done using template_preprocess_HOOK().
* In this case it modifies the output to allow a theme developer to output the
* whole form or gain control over some of its parts in the template file.
*/
/**
* Implements hook_theme().
*
* Defines the theming capabilities provided by this module.
*/
function theming_example_theme($existing, $type, $theme, $path) {
return [
'theming_example_content_array' => [
// We use 'render element' when the item to be passed is a self-describing
// render array (it will have #theme_wrappers)
'render element' => 'element',
],
'theming_example_list' => [
// We use 'variables' when the item to be passed is an array whose
// structure must be described here.
'variables' => [
'title' => NULL,
'items' => NULL,
],
],
'theming_example_text_form' => [
'render element' => 'form',
// In this one the rendering will be done by a template file
// (theming-example-text-form.tpl.php) instead of being rendered by a
// function. Note the use of dashes to separate words in place of
// underscores. The template file's extension is also left out so that
// it may be determined automatically depending on the template engine
// the site is using.
'template' => 'theming-example-text-form',
],
];
}
/**
* Implements hook_preprocess_HOOK().
*/
function theming_example_preprocess_form_element_label(&$variables) {
if (!empty($variables['element']['#attributes']['data-strong'])) {
$variables['title']['#prefix'] = '<strong>';
$variables['title']['#suffix'] = '</strong>';
unset($variables['#attributes']['data-strong']);
}
}
/**
* Implements hook_form_alter().
*
* In Drupal 8+, all forms share the same theme hook (form).
* Use hook_form_alter()/hook_form_FORM_ID_alter() to modify the form array.
*/
function theming_example_form_alter(&$form, FormStateInterface $form_state, $form_id) {
switch ($form_id) {
case 'theming_example_form_select':
// Add data-strong attribute to make title strong.
// @see theming_example_preprocess_form_element_label().
$form['choice']['#label_attributes']['data-strong'] = 1;
// Output choice title separately using h3 header.
$form['title'] = [
'#type' => 'html_tag',
'#tag' => 'h3',
'#value' => $form['choice']['#title'],
'#weight' => -100,
];
// Wrap choice and submit elements in inline container.
$form['choice']['#prefix'] = '<div class="container-inline choice-wrapper">';
$form['submit']['#suffix'] = '</div>';
break;
case 'theming_example_form_text':
// Add data-strong attribute to make title strong.
// @see theming_example_preprocess_form_element_label().
$form['text']['#label_attributes']['data-strong'] = 1;
break;
}
}
/**
* @} End of "defgroup theming_example".
*/
How it works, piece by piece
hook_theme() — the required registration point
Every custom theme hook a module wants to expose must be declared inside hook_theme(). Drupal calls this function on every module while building its internal theme registry, collecting every hook any module wants to register. The signature takes four parameters:
$existing— theme hooks already registered by other modules, in case you want to inspect or extend one.$type— whether the caller is amoduleor atheme.$theme— the machine name of the currently active theme.$path— the filesystem path to the module or theme providing the hook.
The return value is an associative array, one entry per theme hook. The two keys that matter most inside each entry are render element and variables — and choosing correctly between them is the single most common source of confusion for developers new to Drupal theming.
render element vs. variables
'theming_example_content_array' => [
'render element' => 'element',
],
render element is for when whatever gets passed to the theme hook is already a self-describing render array — it comes with its own #-prefixed properties like #items or #title baked in. The name you give here (element) becomes the top-level variable your Twig template receives — exactly the element variable from the previous lesson's template.
'theming_example_list' => [
'variables' => [
'title' => NULL,
'items' => NULL,
],
],
variables is for when you're instead passing a plain associative array whose shape you declare explicitly, key by key. Here, NULL just means "optional, no required default." Each key becomes its own direct variable inside the Twig template — no # prefixes, no digging into a render array structure.
The template key overrides automatic filename derivation
'theming_example_text_form' => [
'render element' => 'form',
'template' => 'theming-example-text-form',
],
By default, as you saw last lesson, Drupal derives a template's filename automatically from the hook name (underscores become dashes, .html.twig gets appended). The explicit template key here overrides that convention, letting the filename differ from the hook name entirely. Note the extension is deliberately omitted — Drupal resolves it based on whatever template engine the site uses (Twig, universally, in Drupal 8 and later).
Template suggestions: overriding at increasing specificity
Here's the part that wasn't visible in the single-template example from last lesson. When Drupal renders any theme hook, it doesn't look for just one filename — it builds an ordered list of template suggestions, from most generic to most specific, and uses the most specific one that actually exists as a file. For the theming_example_list hook, the base template is theming-example-list.html.twig. A theme could add a more specific theming-example-list--node.html.twig to override it only on node pages, or go even further with theming-example-list--node--1.html.twig to target one exact node — following a double-dash (--) naming convention where each segment adds specificity. This is the mechanism (not covered in the module's own code, but built into Drupal core) that lets themes override output surgically, without ever touching module PHP. A module can also actively participate by implementing hook_theme_suggestions_HOOK(), adding its own candidate filenames to the list programmatically.
hook_preprocess_HOOK() — preparing variables before Twig sees them
function theming_example_preprocess_form_element_label(&$variables) {
if (!empty($variables['element']['#attributes']['data-strong'])) {
$variables['title']['#prefix'] = '<strong>';
$variables['title']['#suffix'] = '</strong>';
unset($variables['#attributes']['data-strong']);
}
}
theming_example_preprocess_form_element_label() implements hook_preprocess_HOOK(), where HOOK is form_element_label — a theme hook that core itself already defines. Drupal calls every matching preprocess function, in a defined order (core first, then contrib modules, then the active theme), right before $variables is handed to the template. Because $variables is passed by reference, anything you change here is what the template actually sees.
Walking through the logic: it checks whether this label element was flagged with a custom data-strong HTML attribute (set somewhere else, as you'll see next). If the flag is present, it wraps $variables['title'] in <strong> tags using #prefix and #suffix — two render array properties that inject raw markup immediately before and after an element — then removes the flag so it never leaks into the final rendered HTML as a stray attribute.
Coordinating hook_form_alter() with a preprocess function
function theming_example_form_alter(&$form, FormStateInterface $form_state, $form_id) {
switch ($form_id) {
case 'theming_example_form_select':
$form['choice']['#label_attributes']['data-strong'] = 1;
...
This is where that data-strong flag actually gets set. theming_example_form_alter() implements hook_form_alter() — which, as you learned back in the Hooks topic, fires for every single form on the site, so a switch on $form_id is essential to target only the forms this module cares about. A few operations worth calling out individually:
#label_attributes— a render array property specific to form elements that injects arbitrary HTML attributes directly onto the rendered<label>tag. Settingdata-strong = 1here is exactly the flag the preprocess function above checks for.#type => 'html_tag'— a core render element type that outputs an arbitrary HTML tag with a given value. Here it builds a standalone<h3>to display a field's title outside its normal label position.#weight— controls render order among sibling elements. A weight of-100guarantees this title element renders before everything else in the form.#prefix/#suffixon the choice and submit elements — inject acontainer-inline choice-wrapper<div>around both, so they display inline together instead of stacking vertically.
Put together, this is a genuinely useful pattern: hook_form_alter() plants a signal (the data-strong attribute), and a completely separate preprocess function reacts to that signal later in the pipeline. Neither piece needs to know the internal details of the other, and the Twig template itself stays entirely free of conditional logic.
See it for yourself
Visit the theming example overview page and follow the "Simple page with a list" link, exactly like last lesson — the visible proof is the same page, because it's demonstrating the same underlying registration mechanics from two angles.
What you're looking at is the direct, visible output of the two theme hook registrations examined above: the plain bulleted list comes from core's generic item_list theme hook, while the lettered A/B/C/D list comes from theming_example_list — registered here in hook_theme() with the variables style, then themed by its own dedicated template and CSS class (which you'll meet properly in the next lesson on libraries).
Quick check: you want a theme hook to receive a fully self-describing render array with its own
#-prefixed properties. Do you register it withrender elementorvariables? If you saidrender element, that's correct — savevariablesfor when you're declaring a plain array's shape explicitly.
Key takeaways
hook_theme()is the required registration point for every custom theme hook — without it, Drupal's render system has no idea your template or variables exist.- Use
render elementwhen passing a self-describing render array, andvariableswhen passing a plain associative array with an explicitly declared structure. - Template filenames follow the
machine-name-with-dashes.html.twigconvention automatically; thetemplatekey overrides the auto-derived filename when you need it to. - Template suggestions let a theme override output at increasing specificity (
--node,--node--1, and so on) without ever touching the module's PHP or default template. hook_preprocess_HOOK()receives$variablesby reference, giving you a clean, decoupled place to inject or transform data right before it reaches Twig — keeping conditional logic out of the template entirely.- Coordinating
hook_form_alter()with a preprocess function via a custom data attribute is a reusable pattern: the alter plants a signal, the preprocess function acts on it, and the template never has to know either piece exists.
Coming up next
You've now seen how a module registers theme hooks and prepares data for them — but none of that output has had any real visual styling yet. In the next lesson, we'll look at how a module ships its own scoped CSS (and JS) using Drupal's library system, and how it gets attached to a render array so the browser actually loads it.