The Drupal Render Pipeline: From Array to HTML, Step by Stepfor Drupal 11 , and 10

Last updated :  

This is the last lesson of the Render API topic, and it's less about learning something new and more about seeing the whole picture at once. You've met render arrays, and you've met theme hooks — now let's trace, start to finish, exactly what happens between a controller returning a PHP array and a browser receiving finished HTML. Understanding this full pipeline is what separates "I can copy render array syntax" from "I know why Drupal behaves the way it does."

What you'll learn in this lesson

  • The order render array properties actually get evaluated in, end to end
  • How a custom #type is defined from scratch, using the module's own Marquee element as a real example
  • Why Drupal 11 requires TrustedCallbackInterface for anything that hooks into this pipeline
  • How to reason about where in the pipeline a given problem — wrong HTML, broken caching, a callback that never fires — is actually happening

The source file

Path: modules/render_example/src/Controller/RenderExampleController.php — the same controller from the first lesson in this topic, now read with an eye toward the pipeline it exercises rather than the individual properties.

<?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

Tracing one element through the whole pipeline

Take $build['pre_render_and_post_render'] above and walk it through, step by step, exactly as Drupal's renderer would:

  1. Type resolution. No #type is set, but #markup is present, so Drupal assumes '#type' => 'markup' automatically.
  2. Access check. No #access key here, so the element proceeds (had it been FALSE, everything below would be skipped entirely).
  3. #pre_render callbacks run, in array order. preRenderAddSuffix() receives the element, adds a #suffix, and returns the modified array.
  4. The element becomes an HTML string#markup is output (after XSS filtering), followed by the #suffix just added.
  5. #post_render callbacks run on that finished string. postRenderAddPrefix() appends one more <div> and returns the final markup.
  6. Cache metadata bubbles up to the parent, and the finished HTML is either stored in cache (if cacheable) or returned directly.

That's the pipeline in miniature: resolve type → check access → pre-render → convert to HTML → post-render → cache/return. Every property you learned in the first lesson of this topic is really just a hook into one specific step of this sequence.

Where #theme and #type diverge in that sequence

It's worth being precise about a distinction that's easy to blur: #type resolves to a render element plugin class — a `getInfo()` method supplies defaults, potentially including its own `#theme`, before your properties are even merged in. #theme itself just names a Twig template and hands it variables. So `#type => 'table'` and `#theme => 'item_list'` are not competing choices at the same level — a `#type` plugin frequently sets its own `#theme` internally, as you're about to see with `marquee`. Knowing this order — type resolves first, and may itself introduce a theme hook — is what makes debugging "why isn't my template being used" tractable instead of mysterious.

Defining a brand-new #type from scratch — the Marquee element

// @RenderElement("marquee")
class Marquee extends RenderElementBase {
  public function getInfo() {
    return [
      '#theme' => 'render_example_marquee',
      '#pre_render' => [[self::class, 'preRenderMarquee']],
      '#content' => '',
      '#attributes' => [
        'direction' => 'left',
        'loop' => -1,
        'scrollamount' => 'random',
      ],
    ];
  }
  public static function preRenderMarquee(array $element) {
    if ($element['#attributes']['scrollamount'] == 'random') {
      $element['#attributes']['scrollamount'] = abs(rand(1, 50));
    }
    return $element;
  }
}

This is genuinely the complete lifecycle of a custom render element in one small class. The @RenderElement("marquee") annotation registers the plugin under the ID marquee — that's the exact string you'd use as '#type' => 'marquee' in any render array, anywhere in Drupal, once this module is enabled. getInfo() supplies the defaults: a #theme hook to render through (the one you registered via hook_theme() in the last lesson), and a #pre_render callback of its own. That callback resolves the abstract value 'random' into a real integer before the element ever reaches the Twig template — a clean illustration of exactly what #pre_render is for: translating options and intent into concrete values, at the correct point in the pipeline, before HTML generation begins.

The security requirement: TrustedCallbackInterface

public static function trustedCallbacks() {
  return ['postRenderAddPrefix', 'preRenderAddSuffix', 'lazyBuilder'];
}

Every method used as a #pre_render, #post_render, or #lazy_builder callback anywhere in this class must be listed here, and the class itself must implement TrustedCallbackInterface. This exists because render callbacks are, in effect, arbitrary code execution triggered purely by data in a render array — if any code anywhere could inject a callback reference into an array that later gets rendered, that would be a serious security hole. Requiring an explicit allowlist closes it: Drupal throws a BadMethodCallException the instant it finds a callback that isn't declared, rather than silently executing something nobody vetted.

Reading the pipeline in reverse: debugging by property

Once you can name each step of the pipeline, "something looks wrong" stops being a mystery and starts being a search:

  • Wrong HTML structure? Check #pre_render — that's your last chance to reshape the array before it becomes a string.
  • Markup present but garbled or stripped? Check whether you used #markup (filtered, allows safe HTML) where you needed Markup::create()-wrapped trusted content, or vice-versa.
  • Template not being used? Confirm the hook is actually registered in hook_theme(), and that the filename convention (underscores → hyphens, .html.twig) matches what you named on disk.
  • Stale content after data changes? That's a #cache problem — usually a missing cache tag or context, not a rendering bug at all.
  • Callback silently not firing, or a hard error instead? Check trustedCallbacks() first — an undeclared callback fails loudly in Drupal 11, which is actually the easy case to diagnose.
Heads up: as noted in the previous two lessons, the live /examples/render-example/arrays page — the one page that would let you watch every step above happen for real, in your browser — hangs indefinitely on this install due to a genuine upstream bug in Variable::export() choking on circular object references back to the service container. It's not something wrong with your setup. The code throughout this topic is still exactly what ships in the real module; the screenshot below uses the working "Alter pages and blocks" page instead, which exercises the same pipeline (pre_render, theme hooks, preprocess functions) through a route that doesn't hit the bug.

See it for yourself

Visit /examples/render-example/altering on your DDEV site. Understanding this pipeline is what makes Drupal theming and performance work make sense together, instead of feeling like two unrelated topics.

The render_example module's Alter pages and blocks page demonstrating the render pipeline in action

Quick check: you need to change the actual DOM structure of an element based on some runtime condition — not just append text after the fact. Do you reach for #pre_render or #post_render? If you said #pre_render, you've got it — it's the step where you still have the array, not just a finished string.

Key takeaways

  • The render pipeline runs in a fixed order: resolve #type, check #access, run #pre_render, convert to HTML, run #post_render, then cache/return — every property you've learned hooks into exactly one of these steps.
  • A #type plugin can set its own #theme as a default inside getInfo() — this is how a single '#type' => 'marquee' shorthand carries an entire render-plus-template pipeline with it.
  • Use #pre_render to reshape the array structure before HTML exists; use #post_render only when you need to alter the finished HTML string, since you lose the structured array by that point.
  • Any class registering #pre_render, #post_render, or #lazy_builder callbacks must implement TrustedCallbackInterface and declare every one of them in trustedCallbacks() — Drupal 11 enforces this as a hard security requirement, not a suggestion.
  • When something looks wrong in rendered output, map the symptom to a pipeline stage first (structure vs. HTML string, template not found vs. stale cache vs. callback not firing) — it turns debugging into a search instead of a guessing game.
  • Custom render elements (a #type you define yourself) are a first-class extension point — Drupal core's own elements like table and link use exactly the same mechanism your own module can use.

Coming up next

That completes the Render API. Next up is Theming — where you'll spend more time on the Twig side of this same pipeline: writing template files directly, understanding template suggestions (how Drupal picks which of several possible templates to use), and attaching your own CSS and JavaScript libraries to the pages you build.