Theme Drupal output by building render arrays, Twig templates, hook_theme(), preprocess functions, and CSS/JS library attachments. Use WHENEVER producing visible output in a Drupal module: creating themed markup, building a template, defining a theme hook, attaching CSS or JavaScript libraries, or rendering structured data with #theme or #type render elements. Covers render array types and .libraries.yml. Do NOT use for cache invalidation or cache metadata strategy (use drupal-caching).
Start here. Every controller, block, and form in Drupal returns a render array. Choose the right render array type based on what you need to output.
Simple text or markup?
Use #markup. Content is sanitized by Xss::filterAdmin (allows basic HTML tags).
return ['#markup' => '<p>Hello <em>world</em></p>'];
Plain text (fully escaped)?
Use #plain_text. All HTML is escaped -- safe for user-provided content.
return ['#plain_text' => $user_input];
Custom themed output (your own template)?
Define hook_theme(), use #theme, create a Twig template. This is the standard approach for any structured output.
return [
'#theme' => 'hello_world_salutation',
'#salutation' => $salutation,
'#target' => $target_name,
];
Existing core theme hook (table, item_list, links)?
Use #theme with the core hook name. No need to define your own hook_theme().
return ['#theme' => 'table', '#header' => $header, '#rows' => $rows];
Render element (form elements, containers)?
Use #type. These are standardized components with built-in rendering.
return ['#type' => 'html_tag', '#tag' => 'h2', '#value' => $this->t('Title')];
WRONG: Returning raw HTML strings from controllers (
return '<div>Hello</div>';). Strings bypass caching, theme overrides, altering hooks, and security filtering. RIGHT: Always return render arrays. Even simple output should use#markupor#plain_text, which integrate with Drupal's render pipeline.
CRITICAL: When you define custom theme hooks via
hook_theme(), you MUST actually USE them by returning render arrays with'#theme' => 'your_hook_name'from controllers, blockbuild()methods, or preprocess functions. Declaring a theme hook without using#themein a render array means the template is never rendered. ALSO: Always attach your CSS library in the SAME render array via'#attached' => ['library' => ['module_name/library_name']]. A.libraries.ymlfile without#attachedmeans your CSS is never loaded.
Render arrays are NOT HTML. They are declarative descriptions that Drupal renders through its pipeline (applying cache metadata, security filtering, theme overrides, and asset attachment).
Keys starting with # are properties:
#theme -- which theme hook renders this#markup -- simple sanitized HTML#plain_text -- fully escaped text#type -- render element type#cache -- cache metadata (tags, contexts, max-age)#attached -- libraries, drupalSettings#prefix, #suffix -- HTML wrappers#access -- boolean or AccessResult controlling visibility#weight -- ordering among siblingsKeys NOT starting with # are children (nested render arrays):
$build = [
'#type' => 'container',
'heading' => ['#markup' => '<h2>Title</h2>'],
'content' => ['#markup' => '<p>Body text</p>'],
];
Children are rendered in #weight order and output is concatenated.
Implement hook_theme() in your .module file. This defines the data contract between PHP and your Twig template.
// hello_world.module
/**
* Implements hook_theme().
*/
function hello_world_theme($existing, $type, $theme, $path) {
return [
'hello_world_salutation' => [
'variables' => [
'salutation' => NULL,
'target' => NULL,
'overridden' => FALSE,
],
],
];
}
Rules:
hello_world_salutation) becomes the #theme value in render arraysvariables defines every variable the template can use, with default valuesNULL, empty arrays, or FALSE -- never leave them undefinedhook_theme() implementationWRONG: Omitting variables in
hook_theme(). Every variable the template uses must be declared with a default value, or it will be undefined in Twig and cause silent failures. RIGHT: Declare all variables with defaults:'variables' => ['title' => NULL, 'items' => []]. The variables array IS the template's API contract.
Drupal maps theme hook names to template filenames using a strict convention:
| Hook name | Template filename | Location |
|---|---|---|
hello_world_salutation | hello-world-salutation.html.twig | templates/ |
my_module_card | my-module-card.html.twig | templates/ |
license_plate | license-plate.html.twig | templates/ |
The rule: Underscores in hook name become hyphens in template filename. Extension is always .html.twig. Templates go in the templates/ directory inside your module.
WRONG: Template name not matching hook name. Hook
my_module_blockrequires templatemy-module-block.html.twig, notmy_module_block.html.twigormyModuleBlock.html.twig. RIGHT: Convert every underscore to a hyphen. Place the file intemplates/. Drupal will not find the template if the naming convention is wrong.
{# templates/hello-world-salutation.html.twig #}
<div{{ attributes }}>
{{ salutation }}
{% if target %}
<span class="salutation--target">{{ target }}</span>
{% endif %}
{% if overridden %}
<em>{{ 'Overridden'|t }}</em>
{% endif %}
</div>
| Syntax | Purpose | Notes |
|---|---|---|
{{ variable }} | Output (auto-escaped) | Safe by default |
{% if condition %} | Logic block | {% endif %} to close |
{% for item in items %} | Loop | {% endfor %} to close |
{{ attributes }} | Render attributes object | Classes, id, data-* attributes |
| `{{ 'Text' | t }}` | Translation filter |
| `{{ content | raw }}` | Unescaped output |
Auto-escaping is on by default. This is a security feature. Do NOT use |raw unless you are certain the content has already been sanitized by Drupal's render pipeline.
The attributes variable is an Attribute object passed to templates. It renders HTML attributes and supports:
{# Add classes #}
<div{{ attributes.addClass('my-class', 'another-class') }}>
{# Check for a class #}
{% if attributes.hasClass('active') %}
{# Set arbitrary attribute #}
<div{{ attributes.setAttribute('role', 'banner') }}>
Preprocess functions prepare data for templates. They run before the template renders and can add computed variables, set default attributes, or transform data.
template_preprocess_HOOK(&$variables) -- default preprocessor (in .module file)MODULE_preprocess_HOOK(&$variables) -- module-specific preprocessor// hello_world.module
/**
* Prepares variables for hello_world_salutation templates.
*
* Default template: hello-world-salutation.html.twig.
*/
function template_preprocess_hello_world_salutation(&$variables) {
$variables['attributes']['class'][] = 'salutation';
if ($variables['overridden']) {
$variables['attributes']['class'][] = 'salutation--overridden';
}
}
The $variables['attributes'] array is automatically converted to an Attribute object before the template renders.
Never include CSS or JS with inline <script> or <style> tags. Drupal uses the Libraries API for all asset management.
# hello_world.libraries.yml
hello_world_clock:
version: 1.x
css:
component:
css/hello_world_clock.css: {}
js:
js/hello_world_clock.js: {}
dependencies:
- core/jquery
- core/drupal
- core/once
CSS weight categories (determines load order):
base -- CSS reset, normalizelayout -- page structure, gridcomponent -- discrete UI components (most common)state -- active, hover, disabled statestheme -- visual styling, colors, fonts$build = [
'#theme' => 'hello_world_salutation',
'#salutation' => $salutation,
'#attached' => [
'library' => ['hello_world/hello_world_clock'],
],
];
The library name format is module_name/library_name.
deferWhen a library externalizes a dependency as a global variable (e.g., Vue loaded separately from the consuming bundle), be careful with defer and header attributes:
WRONG: Using
{ attributes: { defer: true } }on a library that provides a global variable (like Vue) consumed by IIFE-format bundles in the footer. Deferred scripts execute AFTER non-deferred footer scripts, so the consuming bundle runs before the global is defined — causing "X is not defined" errors. RIGHT: Omitdeferon libraries that provide globals consumed by other scripts. Useheader: trueandweight: -20to ensure the global loads first. Drupal's library dependency system handles load ordering —deferbreaks it by changing execution timing.
# WRONG: defer causes Vue to execute AFTER kanban.js