Render API and the theming pipeline
Every page Drupal serves is assembled from render arrays: structured PHP arrays that describe output without producing it. This page explains how a render array travels from a controller to HTML, and exactly where modules and themes get to intervene along the way.
What and why
Section titled “What and why”Modules in Drupal do not print HTML. They return render arrays, and the theme system turns those arrays into markup at the last possible moment. The division of labor is strict: modules decide what appears (the data and its structure), themes decide how it appears (the markup and CSS).
Without this indirection, every module would emit its own HTML, and a theme that wanted to change the markup of, say, a menu would have to patch every module that prints one. Because output stays data until rendering, a theme can override any template on the site by shipping one Twig file, without touching module code.
The indirection buys two more things that matter as much as themeability:
- Cacheability. A render array carries metadata describing what it depends on (cache tags), what it varies by (cache contexts), and how long it stays valid (max-age). The render system caches finished HTML fragments and knows precisely when to throw them away. See Caching.
- Deferral. Because output is a description, Drupal can decide late that an expensive part of the page should be rendered lazily, replaced by a placeholder, and streamed in afterwards by BigPipe (the core module that streams placeholdered content in after the main page). A string of HTML allows none of that.
Mental model
Section titled “Mental model”A render array is a description of output, not output. Rendering is a recursive interpreter, the renderer, walking that description depth-first and asking the theme system to produce markup at each node of the tree.
Within any level of the array, keys that start with # are properties (instructions to the Render API), and all other keys are children (the next level of the tree). A render array usually declares what it is in one of three ways:
#type: the array is a render element, a reusable, pre-configured bundle of defaults defined by a plugin (table,details,link, all form controls).#theme: the array is data for a theme hook, rendered by a Twig template that modules register withhook_theme().#markupor#plain_text: the array carries a string directly.#plain_textis fully escaped;#markupis run through a cross-site scripting (XSS) filter that strips all but a permissive list of tags.
From array to HTML
Section titled “From array to HTML”When the renderer reaches an element, it processes it in a fixed order. Knowing this order tells you where any given property takes effect:
- Access. If
#accessisFALSE, or an access result object that is not allowed (neutral or forbidden), the subtree renders as nothing. - Cache lookup. If
#cache['keys']are set and a valid cached copy exists, the renderer returns the cached HTML and skips everything below. - Placeholders and lazy builders. Highly dynamic elements with a
#lazy_buildercallback can be replaced by a placeholder and filled in late, so the rest of the page stays cacheable. #pre_rendercallbacks transform the element while it is still an array. This is the last chance to change structure.- The theme hook renders. If
#themeis set, the theme manager resolves suggestions, runs preprocess, and renders the Twig template. Otherwise the children render recursively and concatenate. #theme_wrapperswrap the rendered children in further templates (a form item wrapping an input, a container wrapping a block).#post_rendercallbacks may filter the finished string.
The renderer processes each element in a fixed order: it checks #access, then looks up the cache, then runs #pre_render callbacks, then renders the #theme hook, then wraps the result with #theme_wrappers, then runs #post_render callbacks. Throughout, cache tags, contexts, max-age, and attached assets bubble up to the parent element. Nothing renders at the moment you build the array.
While all of this happens, two kinds of metadata bubble from every child to its parents, collected in \Drupal\Core\Render\BubbleableMetadata: attached assets (#attached libraries, settings, headers) and cacheability (#cache tags, contexts, max-age). Bubbling is why caching a parent never silently freezes a child that varies by user: the child’s variations become the parent’s variations.
Render elements
Section titled “Render elements”Render elements are plugins. A generic element implements \Drupal\Core\Render\Element\ElementInterface, carries the RenderElement PHP attribute, and usually extends \Drupal\Core\Render\Element\RenderElementBase. Form input elements carry the FormElement attribute and extend \Drupal\Core\Render\Element\FormElementBase. The element class defines default properties; setting #type merges those defaults into your array, which is why a '#type' => 'table' array works with nothing but #header and #rows.
Use an element when one exists. A #type => 'link' respects the route system and outbound processing; hand-rolled <a> markup in #markup does not. Browse the element classes under Drupal\Core\Render\Element to see what core ships, for example Table and Details.
Theme hooks and templates
Section titled “Theme hooks and templates”A theme hook is a named contract: “data shaped like this renders through a template named like that”. Modules register theme hooks by implementing hook_theme(), declaring the input variables and, optionally, an initial preprocess callable that maps raw input onto template-ready variables:
/** * Implements hook_theme(). * * Declares the concepts_systems_badge theme hook. Render arrays that set * '#theme' => 'concepts_systems_badge' are rendered by the default * template, templates/concepts-systems-badge.html.twig, which any theme * can override by shipping a file with the same name. */#[Hook('theme')]public function theme(): array { return [ 'concepts_systems_badge' => [ 'variables' => [ 'label' => NULL, 'count' => 0, ], // The initial preprocess callable derives presentation-ready // variables from the input variables before Twig runs. 'initial preprocess' => static::class . ':preprocessBadge', ], ];}
/** * Prepares variables for badge templates. * * Default template: concepts-systems-badge.html.twig. * * @param array<string, mixed> $variables * The template variables, modified in place. */public function preprocessBadge(array &$variables): void { // Compute in PHP what the template should not have to: the template // only prints variables and toggles classes. $variables['is_empty'] = ((int) $variables['count']) === 0;}The default template lives in the module’s templates/ directory, named after the hook with underscores turned into dashes:
{#/** * @file * Default implementation for the concepts_systems_badge theme hook. * * Available variables: * - label: The badge label. * - count: The number shown on the badge. * - is_empty: TRUE when the count is zero; set in preprocess. */#}<span class="badge{{ is_empty ? ' badge--empty' }}"> {{ label }}: {{ count }}</span>Any theme can override the output by shipping its own concepts-systems-badge.html.twig; the theme’s file wins over the module’s by name alone. That single rule is most of Drupal theming.
Template suggestions
Section titled “Template suggestions”Suggestions let one theme hook resolve to progressively more specific templates. When a node renders, the node module offers node--article--teaser.html.twig, node--article.html.twig, node--teaser.html.twig, and more; the theme system picks the most specific file that exists and falls back to node.html.twig. Turn on Twig debug mode (see Debug a Drupal site) and the candidate list appears as HTML comments in the page source.
Suggestions come from three places:
- A render array can set
#themeto a hook name with a__SUFFIX, such asitem_list__search_results. Missing templates fall back through each__segment. - The module that owns the hook can implement
hook_theme_suggestions_HOOK()to compute suggestions from the data, the way the node module derives them from bundle and view mode (a named rendering configuration such as teaser or full). - Any module or theme can add, remove, or reorder suggestions with
hook_theme_suggestions_HOOK_alter()or the catch-allhook_theme_suggestions_alter().
/** * Implements hook_theme_suggestions_HOOK_alter() for node templates. * * Offers node--promoted.html.twig as a template candidate for promoted * nodes. Themes that do not provide that file fall through to the next * suggestion and finally to node.html.twig, so adding a suggestion never * breaks rendering. */#[Hook('theme_suggestions_node_alter')]public function themeSuggestionsNodeAlter(array &$suggestions, array $variables): void { $node = $variables['elements']['#node'] ?? NULL; if ($node instanceof NodeInterface && $node->isPromoted()) { $suggestions[] = 'node__promoted'; }}The suggestion array is ordered from least to most specific: the last entry that has a matching template wins.
Preprocess
Section titled “Preprocess”Before a template renders, a chain of preprocess steps builds and refines its variables. The order is fixed, and later steps can overwrite earlier ones:
- Default variables shared by all templates (
attributes,title_prefix, and friends), assembled by the theme manager. Modules can alter this baseline withhook_template_preprocess_default_variables_alter(). - The hook’s own
initial preprocesscallable from itshook_theme()definition, which sets up the hook-specific variables. - Module implementations:
hook_preprocess()for every hook, thenhook_preprocess_HOOK()for this hook. - Theme engine preprocess.
- Theme implementations of
hook_preprocess()andhook_preprocess_HOOK(), in the theme’s Hook classes or its.themefile. Themes always run last, so the active theme has the final word.
In a module, implement preprocess as a method on a Hook class:
/** * Implements hook_preprocess_HOOK() for node templates. * * Modules can add or change template variables for theme hooks they did * not define. Everything set here reaches node.html.twig and all of its * suggestions as {{ reading_time }}. */#[Hook('preprocess_node')]public function preprocessNode(array &$variables): void { $node = $variables['node']; if ($node instanceof NodeInterface && $node->hasField('body')) { $words = str_word_count(strip_tags($node->get('body')->getString())); $variables['reading_time'] = (int) ceil($words / 200); }}Core’s own preprocess logic for common templates lives in \Drupal\Core\Theme\ThemePreprocess, an internal service, and in per-module Hook classes registered through initial preprocess. Treat all of it as implementation detail: there is no function you call or override. Your extension points are the preprocess hooks above and template overrides, nothing else.
How core uses it
Section titled “How core uses it”-
Node output, end to end.
NodeThemeHooks::theme()in the node module registers thenodetheme hook with aninitial preprocessmethod, and itshook_theme_suggestions_HOOK()implementation derives suggestions from view mode, bundle, and ID. The flagship theme tutorial overrides exactly this hook; here is its teaser template using a suggestion:web/themes/custom/first_theme/templates/node--teaser.html.twig {#/*** @file* Teaser display of a node, rendered with the card component.** This file demonstrates template suggestions: node--teaser.html.twig is* more specific than node.html.twig, so Drupal uses it whenever a node is* rendered in the "teaser" view mode. Enable Twig debug mode to see every* suggestion as an HTML comment in the page source.** It fills the slots of the card single-directory component defined in* components/card/.*/#}{# With "only", the blocks below see just the variables listed here. #}{% embed 'first_theme:card' with {attributes,heading_level: 3,label,url,content,title_prefix,title_suffix,} only %}{% block heading %}<a href="{{ url }}" rel="bookmark">{{ label }}</a>{% endblock %}{% block content %}{# Modules print contextual links and similar output here. #}{{ title_prefix }}{{ title_suffix }}{{ content }}{% endblock %}{% endembed %} -
The search result line. The search module registers
search_resultwith two input variables and a preprocess method (SearchThemeHooks::preprocessSearchResult()) that turns a raw result into a dozen template-ready variables. It is a compact, readable example of the hook definition, preprocess, template triad. -
The page itself. After the router returns the main content as a render array,
\Drupal\Core\Render\MainContent\HtmlRendererpicks a page display variant. The block module’s variant places blocks into regions around the main content, and the result flows throughpage.html.twigandhtml.html.twiglike any other theme hook. The page is not special; it is the same pipeline at the top of the tree. See The request lifecycle for everything before this point. -
Forms. Form arrays are render arrays whose elements are FormElement plugins, with validation and submission layered on top. See Build and alter forms.
Common misconceptions
Section titled “Common misconceptions”-
“Building HTML strings is faster than render arrays.” It is faster only until the first cache miss matters. A string cannot declare what it depends on, cannot vary per user without breaking caching for everyone, and cannot be placeholdered or streamed. Render arrays are how Drupal makes dynamic pages cacheable at all.
-
“I should call or override the
template_preprocessfunctions I see referenced in old code.” Those functions have moved into internal classes such as\Drupal\Core\Theme\ThemePreprocessand module Hook classes, wired up through theinitial preprocesskey. They were never extension points. Implementhook_preprocess_HOOK()(modules) orTHEME_preprocess_HOOK()(themes) instead. -
“
#markuplets me output whatever HTML I want.”#markuppasses through an XSS filter that strips disallowed tags such asscriptandstyle, and removesstyleandon*event attributes and unsafe URL protocols from the tags it keeps. That is a feature. If your markup is more than a paragraph with basic tags, define a theme hook and template; if you need plain text escaped, use#plain_text. -
“The first suggestion in the array is the most important.” The opposite: the renderer scans suggestions from the end of the array, so the last entry is the highest priority. Appending to
$suggestionsin an alter hook makes your suggestion the new top candidate. -
“Rendering happens when I build the array.” Nothing renders until the response is assembled. Rendering an array yourself mid-request strands the bubbled asset and cacheability metadata, and the renderer throws a
LogicExceptionwhenrender()runs outside a render context. Return arrays and let the pipeline finish; when you genuinely need markup (in a test, a mail body), use the renderer’srenderInIsolation(). -
“Cached output means stale output.” Cache tags bubble up from every entity an array touches, and saving the entity invalidates each tagged fragment immediately. If you see stale output, a tag is missing, not the system misbehaving. The Caching page covers how to find which.
Where to go next
Section titled “Where to go next”- Twig in Drupal for the template side: syntax, filters, attributes, and debugging.
- Single-directory components for the component layer built on top of these templates.
- Blocks and Build and alter forms to produce render arrays from your own module.
- Caching for the metadata that travels with every render array.
- The
Render APIandtheme systemtopics on api.drupal.org for the full property and hook reference.
Was this page helpful?
Drupal is a registered trademark of Dries Buytaert.