You've now written PHP that reads config, talks to the database, and reacts to events. But none of that code has ever produced a single line of HTML directly — and that's not an accident. In this lesson you'll meet the data structure that sits at the center of literally everything Drupal ever puts on a screen: the render array.
This is one of the bigger lessons in the course, because a single real-world file — RenderExampleController::arrays() — packs in almost every major render array feature Drupal offers. Don't worry about memorizing all of it in one sitting. Treat this lesson as a reference you'll come back to.
What you'll learn in this lesson
- Why Drupal modules build PHP arrays instead of writing HTML strings directly
- The one rule that splits every render array key into two categories: properties and children
- The most common properties you'll reach for —
#markup,#plain_text,#theme,#type,#access - How caching, deferred rendering, and access control are all built directly into this one array structure
Why arrays instead of HTML strings?
Imagine every Drupal module just echoed HTML directly. Themes could never restyle that markup without hacking module code. Drupal couldn't tell which parts of a page were safe to cache and which weren't. Nothing could be conditionally hidden based on permissions without littering your code with if statements. A render array solves all of this at once: instead of describing HTML, you describe intent — "here's some markup," "here's a table," "only show this to logged-in users" — and Drupal's Render API decides how and when to actually turn that into a string of HTML. This is what lets Drupal do lazy rendering (skip work for content that's never displayed), intelligent caching, and clean theme overrides, all without your module code knowing anything about any of it.
The source file
Path: modules/render_example/src/Controller/RenderExampleController.php
<?php
namespace Drupal\render_example\Controller;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Render\Markup;
use Drupal\Core\Security\TrustedCallbackInterface;
use Drupal\examples\Utility\DescriptionTemplateTrait;
use Drupal\Core\Link;
use Drupal\Core\Render\Element;
use Drupal\Core\Url;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\Component\Utility\Variable;
use Drupal\Core\Session\AccountInterface;
/**
* Provides module description page and examples of building render arrays.
*
* Controllers that respond to a route should always return their content as
* a renderable array. See the arrays() method below as an example.
*
* @ingroup render_example
*/
class RenderExampleController extends ControllerBase implements TrustedCallbackInterface {
use DescriptionTemplateTrait;
/**
* Constructs a new BlockController instance.
*
* @param \Drupal\Core\Session\AccountInterface $current_user
* The current user.
*/
public function __construct(AccountInterface $current_user) {
$this->currentUser = $current_user;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('current_user')
);
}
/**
* {@inheritdoc}
*/
protected function getModuleName() {
return 'render_example';
}
/**
* Examples of defining content using renderable arrays.
*
* Methods on a controller that are the target of a route should return a
* renderable array which contains any content to display for that route.
*/
public function arrays() {
// The core structure of the Render API is the render array, which is a
// hierarchical associative array containing data to be rendered and
// properties describing how the data should be rendered. Whenever a module
// needs to output content it should do so be defining that content as a
// renderable array. Below we'll look at some common examples of how render
// arrays can be used to define content.
$build = [];
// CSS and JavaScript libraries can be attached to elements in a renderable
// array. This way, if the element ends up being rendered and displayed you
// know for sure the CSS/JavaScript will also be included. But, if for
// some reason the element isn't ever rendered then Drupal can skip the
// unnecessary extra files.
//
// Learn more about attaching CSS and JavaScript libraries with the
// #attached property here:
// https://api.drupal.org/api/drupal/core%21lib%21Drupal%21Core%21Render%21theme.api.php/group/theme_render/#sec_attached
$build['#attached'] = [
'library' => [
'render_example/render-example.library',
],
];
// Renderable arrays have two kinds of key/value pairs: properties and
// children. Properties have keys starting with '#' and their values
// influence how the array will be translated to a string. Children are all
// elements whose keys do not start with a '#'. Their values should be
// renderable arrays themselves.
//
// This example defines a new element, 'simple', that contains two
// properties; '#markup' and '#description'. This is the quickest way to
// output a string of HTML.
$build['simple'] = [
'#markup' => '<p>' . $this->t('This page contains examples of various content elements described using render arrays. Read the code and comments in \Drupal\render_example\Controller\RenderExampleController::arrays() for more information.') . '</p>',
'#description' => $this->t('Example of using #markup'),
];
// Additional properties can be used to further define the content. In this
// case '#prefix' and '#suffix' are being used to provide strings to add
// before, and after, the main content. This is useful because now the tag
// being used to wrap the block of content can be easily changed without
// having to worry about the content. Or, the tag can easily be left out
// during rendering if for example the content is being output as JSON.
//
// There is a set of common properties that can be used for all elements in
// a render array. These are defined by
// \Drupal\Core\Render\Element\RenderElementBase. Most elements also have
// additional element type specific properties.
//
// Figuring out what additional properties are available requires first
// determining what sort of render element you're dealing with. Look for the
// presence of one of these properties to start:
// - #markup, or #plain_text: These are the simplest render arrays, and are
// used to display simple strings of text. In addition to the common set
// of properties available for all elements #markup elements can use the
// #allowed_tags property, an array of additional tags to allow when the
// HTML string is run through \Drupal\Component\Utility\Xss::filterAdmin()
// to strip out possible XSS vectors.
// - #theme: The presence of #theme indicates that the array contains data
// to be themed by a particular theme hook. The available properties will
// depend on the specific theme hook. See the example below for more about
// determining what properties to use.
// - #type: The presence of #type indicates that the array contains data and
// options for a particular type of "render element" (for example, 'form',
// 'textfield', 'submit', for HTML form element types; 'table', for a
// table with rows, columns, and headers). The additional properties will
// depend on the render element type, and are documented on the class that
// defines the element type.
//
$build['simple_extras'] = [
'#description' => $this->t('Example of using #prefix and #suffix'),
// Note the addition of '#type' => 'markup' in this example compared to
// the one above. Because #markup is such a commonly used element type you
// can exclude the '#type' => 'markup' line and it will be assumed
// automatically if the '#markup' property is present.
'#type' => 'markup',
'#markup' => '<p>' . $this->t('This one adds a prefix and suffix, which put a blockquote tag around the item.') . '</p>',
'#prefix' => '<blockquote>',
'#suffix' => '</blockquote>',
];
// In addition to #markup, you can also use #plain_text to output, you
// guessed it, strings of plain text. This indicates that the array contains
// text which should be escaped before it is displayed.
$build['simple_text'] = [
'#plain_text' => '<em>This is escaped</em>',
'#description' => $this->t('Example of using #plain_text'),
];
// Using the '#theme' property for an element specifies that the array
// contains data to be themed by a particular theme hook. Essentially using
// a Twig template to generate the HTML for an element. Modules define theme
// hooks by implementing hook_theme(), which specifies the input "variables"
// used to provide data and options; if a hook_theme() implementation
// specifies variable 'separator', then in a render array, you would provide
// this data using the '#separator' property.
//
// @see hook_theme()
$build['theme_element'] = [
// The '#theme' property can be set to any valid theme hook. For more
// information about theme hooks, and to discover available theme hooks
// that you can use when creating render arrays see the documentation for
// hook_theme().
//
// Many of the most commonly used theme hooks are defined in
// drupal_common_theme().
'#theme' => 'item_list',
'#title' => $this->t('Example of using #theme'),
// The #items property is specific to the 'item_list' theme hook, and
// corresponds to the variable {{ items }} in the item-list.twig.html
// template file.
'#items' => [
$this->t('This is an item in the list'),
$this->t('This is some more text that we need in the list'),
],
];
// Using the '#type' property for an element specifies that the array
// contains data and options for a particular type of "render element".
// Render element types can be thought of as prepackaged render arrays that
// provide default values for a set of properties as well as code that
// will perform additional processing on the array before it is rendered.
//
// As an example take a look at the code in
// \Drupal\Core\Render\Element\Table::getInfo(). Notice that it is defining
// values for #theme, and #process? These values will be merged with
// whatever properties you define in your code.
//
// In addition, most render element types have type specific properties. A
// table for example has #header, and #rows properties. The easiest way to
// determine what element type specific properties exist is to read the
// documentation for the class that defines the element type. Don't forget
// that it will also inherit properties used by any class it is extending.
//
// There are two types of render element types:
// - Generic elements: Generic render element types encapsulate logic for
// generating HTML and attaching relevant CSS and JavaScript to the page.
// These include things like link, table, and drop button elements.
// - Form elements: Most of the render element types provided by core
// represent the various widgets you might use on a form. Text fields,
// password fields, and file upload buttons for example. These elements
// are intended to be used in conjunction with a form controller class
// and have additional properties such as `#required`, and
// `#element_validate`, related to their use as part of a form. For more
// on form elements check out the fapi_example module.
$build['table'] = [
// The value used for #type is the ID of the plugin that implements the
// element type you want to use. This can be inferred from the annotation
// for the element.
// You can also find a list of element types provided by Drupal core here
// https://api.drupal.org/api/drupal/elements.
'#type' => 'table',
'#caption' => $this->t('Our favorite colors.'),
'#header' => [$this->t('Name'), $this->t('Favorite color')],
'#rows' => [
[$this->t('Teal'), $this->t('teal')],
[$this->t('Amber'), $this->t('green')],
[$this->t('Blake'), $this->t('#063')],
[$this->t('Enid'), $this->t('indigo')],
[$this->t('Joe'), $this->t('green')],
],
'#description' => $this->t('Example of using #type.'),
];
// Render arrays can be nested any level deep. This allows you to group
// like things together. A great example of this is the $page array used in
// conjunction with the page.html.twig template. The top level contains all
// the regions, each of which contain the blocks placed in that region,
// which in turn contain their own content. In fact, when this array is
// ultimately displayed on a page it will be as part of the $page array.
$build['nested_example'] = [
'#description' => $this->t('Example of nesting elements'),
'#markup' => '<p>' . $this->t('Render arrays can contain any number of nested elements. During rendering, the innermost elements are rendered first, and their output is incorporated into the parent element.') . '</p>',
'nested_child_element' => [
// An un-ordered list of links.
// See /core/modules/system/templates/item-list.html.twig.
'#theme' => 'item_list',
'#title' => $this->t('Links'),
'#list_type' => 'ol',
'#items' => [
Link::fromTextAndUrl($this->t('Drupal'), Url::fromUri('https://www.drupal.org')),
Link::fromTextAndUrl($this->t('Not Drupal'), Url::fromUri('https://wordpress.org/')),
],
],
];
// Example of adding a link using the #link element type.
$build['nested_example']['another_nested_child'] = [
// See \Drupal\Core\Render\Element\Link.
'#type' => 'link',
'#title' => $this->t('A link to example.com'),
'#url' => Url::fromUri('https://example.com'),
];
// The #theme_wrappers property can be used to provide an array of theme
// hooks which provide the envelope or "wrapper" of a set of child elements.
// The theme function finds its element children (the sub-arrays) already
// rendered in '#children'.
$build['theme_wrappers demonstration'] = [
'#description' => $this->t('Example of using #theme_wrappers'),
'child1' => ['#markup' => $this->t('Markup for child1')],
'child2' => ['#markup' => $this->t('Markup for child2')],
'#theme_wrappers' => [
'render_example_add_div',
],
];
// Use the #access property to control who can see what content. If an
// element in an render array has its #access property set to FALSE it will
// be removed from the array before rendering. And thus not visible.
$build['access_example'] = [
'#description' => $this->t('Example of using #access to control visibility'),
'#markup' => $this->t('This text is only visible to authenticated users.'),
'#access' => $this->currentUser->isAuthenticated(),
];
// Some properties define callbacks, which are callable functions or methods
// that are triggered at specific points during the rendering pipeline.
$build['pre_render_and_post_render'] = [
'#description' => $this->t('Example of using #pre_render and #post_render'),
'#markup' => '<div style="color:green">' . $this->t('markup for pre_render and post_render example') . '</div>',
// #pre_render callbacks are triggered early in the rendering process,
// they get access to the element in the array where the callback is
// named, and all of its children. They can be used to do things like
// conditionally alter the value of a property prior to the array being
// rendered to HTML.
'#pre_render' => [static::class . '::preRenderAddSuffix'],
// #post_render callbacks are triggered after the array has been rendered
// and can operate on the rendered HTML. They also have access to the
// original array for context.
'#post_render' => [static::class . '::postRenderAddPrefix'],
];
// Properties that contain callbacks can also reference methods on a class
// in addition to functions. See
// \Drupal\render_example\Controller\RenderExampleController::preRender()
// @todo This doesn't work, we need to fix it.
// https://www.drupal.org/project/examples/issues/2986435
// $build['#pre_render'] = [static::class, 'preRender'];.
// Caching is an important part of the Render API, converting an array to a
// string of HTML can be an expensive process, and therefore whenever
// possible the Render API will cache the results of rendering an array in
// order to improve performance.
//
// When defining a render array you should use the #cache property to make
// an element cacheable.
$build['cache_demonstration'] = [
'#description' => $this->t('#cache demonstration'),
// This string contains information that is specific to the user who is
// currently viewing the page. We can cache it, and re-use the string any
// time the same user views the page again. However, if the user changes,
// or if the user changes their name, we need to expire the cached data
// and rebuild it so that it is accurate.
'#markup' => $this->t('Hello @name, welcome to the #cache example.', ['@name' => $this->currentUser->getAccountName()]),
// The #cache property is used to provide metadata about the element being
// cached, and the conditions under which it should be expired. This can
// be time based, or context based. You can read more about caching
// render arrays here
// https://www.drupal.org/docs/8/api/render-api/cacheability-of-render-arrays
'#cache' => [
// The "current user" is used above, which depends on the request, so
// we tell Drupal to vary by the 'user' cache context.
'contexts' => [
'user',
],
],
];
// A #lazy_builder callback can be used to build a highly dynamic section of
// a render array from scratch. This, combined with the use of placeholders,
// allows the renderer to cache some, but not all, portions of a render
// array. Without #lazy_builders, if any element in the render tree is
// uncacheable the whole tree would need to be re-rendered every time.
//
// The general rendering flow is as follows:
// - Check for cached version of output from previous rendering, if it
// exists replace any placeholders in the rendered output with their
// dynamic content as generated by the #lazy_builder callback, and return
// the resulting HTML.
// - If no cached version exists render the array to HTML, when an element
// that can be placeholdered is encountered insert a placeholder, cache
// the HTML after rendering for next time, replace the placeholders with
// their dynamic content, and return the resulting HTML.
//
// This is especially noticeable when used in conjunction with modules like
// Big Pipe which do rendering of a page in multiple passes vs. the default
// single flush renderer.
//
// See \Drupal\block\BlockViewBuilder::viewMultiple() for an example from
// core.
$build['lazy_builder'] = [
// Set the value of the #lazy_builder property to an array, the first key
// of the array is the method, service, or function, to call in oder to
// generate the dynamic data. The second argument is an array of any
// arguments to pass to the callback. Arguments can be only primitive
// types (string, bool, int, float, NULL).
'#lazy_builder' => [
static::class . '::lazyBuilder',
[$this->currentUser->id(), 'Y-m-d'],
],
// #lazy_builder callbacks can be used in conjunction with
// #create_placeholder to tell the renderer that instead of simply calling
// the #lazy_builder code right away, to instead insert a placeholder and
// delay execution of the #lazy_builder code until it's needed.
//
// This is somewhat analogous to the way Drupal uses the PSR-4 autoloading
// standard to "lazy" load PHP files that contain the definition of a
// class only if, and when, that class is used.
//
// To force a element to use a placeholder set #create_placeholder to
// TRUE.
//
// Alternatively you could include #cache metadata (see above) and allow
// the Render API to use that metadata to automatically determine based on
// the existence of high-cardinality cache contexts in the subtree whether
// or not the element should use a placeholder.
'#create_placeholder' => TRUE,
];
// Example of the marquee element type defined by
// \Drupal\render_example\Element\Marquee.
$build['marquee'] = [
'#description' => $this->t('Example custom element type'),
'#type' => 'markup',
'marquee_element' => [
'#type' => 'marquee',
'#content' => $this->t('Hello world!'),
],
];
$output = [];
// We are going to create a new output render array that pairs each
// example with a set of helper render arrays.
foreach (Element::children($build) as $key) {
if (isset($build[$key])) {
$output[$key] = [
'#theme' => 'render_array',
'description' => [
'#type' => 'markup',
'#markup' => $build[$key]['#description'] ?? '',
],
'rendered' => $build[$key],
'debug_value' => [
'#type' => 'markup',
'#markup' => htmlentities(Variable::export($build[$key])),
],
];
}
}
foreach (Element::properties($build) as $key) {
$output[$key] = $build[$key];
}
return $output;
}
/**
* A #pre_render callback, expand array to include additional example info.
*
* This method is called during the process of rendering the array generated
* by \Drupal\render_example\Controller\RenderExampleController::arrays().
*
* This also demonstrates how a #pre_render callback could be used to expand
* a relatively simple array into multiple individual renderable elements
* based on application logic.
*
* @param array $element
* Pre render methods (and functions) get a single argument that is the
* render API array representing the element where the #pre_render property
* was defined, and all of it's children.
*
* @return array
* Pre render methods (and functions) should return the modified render
* array.
*/
public static function preRender(array $element) {
// For each first level child element lets add some additional helpful
// output. \Drupal\Core\Render\Element::children() is a utility method that
// allows you to quickly identify all children of a render array. That is
// those key/value pairs whose key does not start with a '#'.
foreach (Element::children($element) as $key) {
$child = $element[$key];
unset($element[$key]);
if (isset($child['#description'])) {
$element[$key] = [
// The value from the #description property will be used as a title
// for this element in the final output.
'description' => [
'#markup' => $child['#description'],
],
// Move the original element to 'rendered'. The rendering process is
// recursive so this will still be located, and rendered to HTML.
'rendered' => $child,
// Export the element definition as a string of text so we can display
// the array that was used to create the rendered output just below
// the output.
'debug_value' => [
'#markup' => htmlentities(Variable::export($child)),
],
'#theme' => 'render_array',
];
}
}
// Return our modified version of the original $element.
return $element;
}
/**
* Example #lazy_builder callback.
*
* Demonstrates the use of a #lazy_builder callback to build out a render
* array that can be substituted into the parent array wherever the cacheable
* placeholder exists.
*
* This method is called during the process of rendering the array generated
* by \Drupal\render_example\Controller\RenderExampleController::arrays().
*
* @param string $date_format
* Date format to use with \Drupal\Core\Datetime\DateFormatter::format().
*
* @return array
* A renderable array with content to replace the #lazy_builder placeholder.
*/
public static function lazyBuilder($date_format) {
$build = [
'lazy_builder_time' => [
'#markup' => '<p>' . \Drupal::translation()->translate('The current time is @time', [
'@time' => \Drupal::service('date.formatter')->format(\Drupal::time()->getRequestTime(), 'long'),
]) . '</p>',
],
];
return $build;
}
/**
* Example '#post_render' callback function.
*
* Post render callbacks are triggered after an element has been rendered to
* HTML and can act upon the final rendered string.
*
* This function is used as a post render callback in
* Drupal\render_example\Controller\RenderExampleController::arrays().
*
* @param string $markup
* The rendered element.
* @param array $element
* The element which was rendered (for reference)
*
* @return string
* Markup altered as necessary. In this case we add a little postscript.
*
* @see \Drupal\render_example\Controller\RenderExampleController::arrays()
*/
public static function postRenderAddPrefix($markup, array $element) {
$markup .= '<div style="color:blue">This markup was added after rendering by a #post_render callback.</div>';
return $markup;
}
/**
* Example '#pre_render' function.
*
* Pre render callbacks are triggered prior to rendering an element to HTML
* and are given the chance to manipulate the renderable array. Any changes
* they make will be reflected in the final rendered HTML.
*
* We need to wrap suffix in a Markup object.
* Otherwise, style attribute will be removed by Xss
*
* @see \Drupal\Component\Utility\Xss::filter()
*
* This function is used as a post render callback in
* \Drupal\render_example\Controller\RenderExampleController::arrays().
*
* @param array $element
* The element which will be rendered.
*
* @return array
* The altered element. In this case we add a #prefix to it.
*
* @see \Drupal\render_example\Controller\RenderExampleController::arrays()
*/
public static function preRenderAddSuffix(array $element) {
$element['#suffix'] = Markup::create('<div style="color:red">'
. t('This #suffix was added by a #pre_render callback.') . '</div>');
return $element;
}
/**
* {@inheritDoc}
*/
public static function trustedCallbacks() {
return ['postRenderAddPrefix', 'preRenderAddSuffix', 'lazyBuilder'];
}
}
How it works
Every key is either a property or a child — nothing else
This is the one rule that unlocks everything else in this lesson. Every key in a render array is either:
- A property: the key starts with
#, and its value controls how something renders —#markup,#type,#theme,#cache, and so on. - A child: the key does not start with
#, and its value is itself a nested render array — content to be rendered and folded into the parent.
Drupal has two small utility functions that read this distinction directly: Element::children($array) returns just the non-# keys, and Element::properties($array) returns just the #-prefixed ones. You'll see both used at the bottom of arrays() to reassemble the final output — and you'll reach for them constantly once you start writing your own render arrays.
#attached — loading CSS/JS only when needed
$build['#attached'] = [
'library' => [
'render_example/render-example.library',
],
];
This tells Drupal to load a library — defined in the module's .libraries.yml file — but only if this render array actually ends up on the page. If, for some reason, this element never gets rendered (hidden by #access, say), the CSS/JS is never requested at all. That's the whole point of describing intent instead of writing HTML directly: Drupal gets to make smart decisions on your behalf.
#markup — the simplest way to output HTML
$build['simple'] = [
'#markup' => '<p>' . $this->t('...') . '</p>',
];
The value is treated as already-safe HTML. Drupal still runs it through a filter that strips genuinely dangerous tags (Xss::filterAdmin()), but ordinary markup like <p>, <strong>, or <a> passes straight through. Always wrap user-facing text in $this->t() before placing it in #markup — never put raw, untrusted user input there.
#plain_text — the escaped alternative
$build['simple_text'] = [
'#plain_text' => '<em>This is escaped</em>',
];
This is the sibling property to #markup, and the one you reach for when the content isn't trusted HTML. Drupal HTML-encodes the value, so the literal string <em>This is escaped</em> shows up on screen exactly as typed — angle brackets and all — instead of rendering as italic text. Use #plain_text any time you're outputting something that came from a user, an API, or anywhere else you can't fully trust.
#type — pre-packaged render elements
$build['table'] = [
'#type' => 'table',
'#caption' => $this->t('Our favorite colors.'),
'#header' => [...],
'#rows' => [...],
];
A render element "type" is a small PHP class that ships with its own default properties and its own processing logic. Setting '#type' => 'table' loads the core Table element, which already knows it should use the table theme hook and understands #header/#rows/#caption as its own vocabulary. You get all of that for free just by naming the type — the full list of built-in types lives at api.drupal.org/api/drupal/elements.
#theme — handing off to a Twig template
$build['theme_element'] = [
'#theme' => 'item_list',
'#title' => $this->t('Example of using #theme'),
'#items' => [...],
];
Where #type loads a whole pre-built element, #theme just names a Twig template (registered via hook_theme() — the very next lesson) and passes your properties to it as variables. Here, #items becomes the {{ items }} variable inside item-list.html.twig. This is how Drupal keeps HTML generation out of PHP entirely: your controller supplies data, the template supplies markup.
#markup and #type are absent from an element but #markup does show up, Drupal quietly assumes #type => 'markup' for you. That's why $build['simple'] above doesn't bother declaring a type at all.Nesting — children render before parents
$build['nested_example'] = [
'#markup' => '<p>...</p>',
'nested_child_element' => [
'#theme' => 'item_list',
'#items' => [
Link::fromTextAndUrl($this->t('Drupal'), Url::fromUri('https://www.drupal.org')),
],
],
];
$build['nested_example']['another_nested_child'] = [
'#type' => 'link',
'#title' => $this->t('A link to example.com'),
'#url' => Url::fromUri('https://example.com'),
];
Render arrays nest to any depth, and Drupal always renders from the bottom up — the deepest children first, with their finished HTML bubbling up into the parent. Notice too that a child can be added after the array is first created, just by assigning to a new key (another_nested_child) — and that a Link object dropped straight into #items renders correctly, because Drupal's object-oriented Link/Url APIs are fully render-array-aware.
#theme_wrappers — wrapping after the fact
$build['theme_wrappers demonstration'] = [
'child1' => ['#markup' => $this->t('Markup for child1')],
'child2' => ['#markup' => $this->t('Markup for child2')],
'#theme_wrappers' => [
'render_example_add_div',
],
];
While #theme renders an element, #theme_wrappers runs after all of an element's children have already been rendered, and gets access to that pre-rendered HTML via a special #children variable. This is the clean way to wrap a group of independent children in a container — a <div>, a <section> — without editing the children themselves.
#access — the secure way to hide content
$build['access_example'] = [
'#markup' => $this->t('This text is only visible to authenticated users.'),
'#access' => $this->currentUser->isAuthenticated(),
];
When #access evaluates to FALSE, the Render API strips the entire element — before it ever becomes HTML. Nothing is sent to the browser, and no attached libraries load either. Compare that to hiding content with CSS, or wrapping already-built markup in an if after the fact: those approaches still leak the content into the page source or waste work building something that's thrown away. #access is correct, secure, and cheap all at once — this is the pattern to reach for, not an if statement around your #markup.
#pre_render and #post_render — hooking into the pipeline
$build['pre_render_and_post_render'] = [
'#markup' => '<div style="color:green">...</div>',
'#pre_render' => [static::class . '::preRenderAddSuffix'],
'#post_render' => [static::class . '::postRenderAddPrefix'],
];
A #pre_render callback runs just before an element becomes HTML — it receives the array and must return a (possibly modified) array. A #post_render callback runs just after — it receives the finished HTML string and must return a (possibly modified) string. preRenderAddSuffix() below uses the former to inject a colored #suffix, wrapping it in Markup::create() specifically so the inline style attribute survives XSS filtering. postRenderAddPrefix() uses the latter to append an extra <div> after the rendered output.
TrustedCallbackInterface and list every callback method in a trustedCallbacks() method (see the bottom of the class above). This is a Drupal 11 security requirement — it stops arbitrary code from being smuggled in as a render callback. Skip it and Drupal throws a hard error the moment the callback would run.#cache — telling Drupal how to store this element
$build['cache_demonstration'] = [
'#markup' => $this->t('Hello @name, welcome to the #cache example.', ['@name' => $this->currentUser->getAccountName()]),
'#cache' => [
'contexts' => [
'user',
],
],
];
Building HTML from an array can be expensive, so wherever possible Drupal caches the result. #cache is how you tell it the rules. Here, contexts: ['user'] means "cache a separate copy of this element per user" — because the greeting text is different for every visitor. Other common contexts include url, route, and language. Two more sub-keys you'll meet elsewhere: tags (invalidate the cache when specific entities change) and max-age (expire after N seconds).
#lazy_builder and #create_placeholder — deferring the truly dynamic parts
$build['lazy_builder'] = [
'#lazy_builder' => [
static::class . '::lazyBuilder',
[$this->currentUser->id(), 'Y-m-d'],
],
'#create_placeholder' => TRUE,
];
This is the mechanism that lets one part of a page stay dynamic while the rest of the page stays fully cached. A #lazy_builder callback is called only when its content is actually needed, and it can only accept primitive arguments — strings, numbers, booleans, or null, never objects — because those arguments might themselves be serialized into a cache entry. Setting #create_placeholder to TRUE tells the renderer to insert a placeholder into the cached HTML now and fill it in later — this is exactly the mechanism BigPipe (which you've already seen headers from, in earlier lessons) uses to stream a page in stages.
Custom element types — #type => 'marquee'
Core types like table and link aren't special — any module can register its own. The marquee type here is defined entirely by this module in \Drupal\render_example\Element\Marquee, proving that #type is an open, extensible vocabulary, not a fixed list you're limited to.
The final assembly loop
foreach (Element::children($build) as $key) {
if (isset($build[$key])) {
$output[$key] = [
'#theme' => 'render_array',
'description' => [...],
'rendered' => $build[$key],
'debug_value' => [...],
];
}
}
foreach (Element::properties($build) as $key) {
$output[$key] = $build[$key];
}
return $output;
Everything above builds up $build as a bunch of independent examples. This closing loop is where the "properties vs. children" rule pays off directly: it walks every child of $build, wraps each one in a render_array themed element (showing a description, the live rendered output, and a debug dump side by side), while copying top-level properties like #attached straight through untouched. This pattern — build a working array, then reformat it for display — is genuinely reusable any time you're building a demo, documentation, or admin overview page.
Quick check: you want to output a username that came straight from a form submission, with no HTML processing. Do you reach for
#markupor#plain_text? If you said#plain_text, you've got it — anything not already trusted, safe HTML gets escaped, not passed through.
/examples/render-example/arrays page — the one that actually runs every example above — hangs indefinitely instead of rendering. The cause is upstream in this exact file: the final assembly loop calls Variable::export() (a debug-dump helper built on PHP's var_export()) on render array values that contain Drupal API objects like TranslatableMarkup and Url. Those objects hold references back into Drupal's service container, and var_export() can't safely walk that circular object graph — it never finishes. This is a genuine bug in the Examples project itself, not something wrong with your site. The screenshot below shows the module's working overview page instead; every line of code above is still exactly what ships in the real, current version of this file.See it for yourself
Visit /examples/render-example on your DDEV site, then follow the "Demonstration of render array usage" link.
Key takeaways
- Every render array key is either a property (starts with
#, controls rendering) or a child (a nested render array) — this single distinction underlies the whole Render API. - Use
#markupfor trusted HTML,#plain_textfor untrusted or literal text,#themeto hand off to a Twig template by hook name, and#typeto load a pre-packaged element plugin with its own defaults. #attachedshould always be how you load CSS/JS — it guarantees assets only load when the element they belong to is actually rendered.#accessis the correct, secure way to conditionally hide content: it strips the element entirely before rendering, rather than hiding already-built HTML after the fact.#pre_renderand#post_renderlet you hook into the render pipeline at two different points — before and after HTML generation — but any class using them must implementTrustedCallbackInterface.#cache, paired with#lazy_builderand#create_placeholder, is how Drupal keeps most of a page fully cached while still delivering per-request dynamic content where it's actually needed.
Coming up next
You've just seen #theme point at Twig templates by name — but where do those names actually come from, and how does Drupal know which template file matches which hook? The next lesson opens up hook_theme(), the registration function that makes the connection between a render array and the template that turns it into HTML.