Implementing lazy_builder for dynamic formsfor Drupal 8 , 9 , 10 , and 11

Last updated :  

A restaurant doesn't plate your dessert the moment you sit down — it waits until you've actually ordered it, so the kitchen never wastes effort on food nobody eats yet. #lazy_builder is that same idea applied to a render array: instead of building an expensive piece of your form right now, you leave behind a placeholder and a set of instructions for building it later, only if and when it's actually needed to produce the final output.

What you'll learn in this lesson

  • What a #lazy_builder callback actually defers, and why that matters for caching
  • The strict argument-type rule #lazy_builder callbacks must follow, and why it exists
  • How #create_placeholder works together with #lazy_builder

The real #lazy_builder pattern

This is the actual working example from Drupal's Render Example module:

$build['lazy_builder'] = [
  '#lazy_builder' => [
    static::class . '::lazyBuilder',
    [$this->currentUser->id(), 'Y-m-d'],
  ],
  '#create_placeholder' => TRUE,
];

public static function lazyBuilder($date_format) {
  $build = [
    'lazy_builder_time' => [
      '#markup' => '

' . \Drupal::translation()->translate('The current time is @time', [ '@time' => \Drupal::service('date.formatter')->format(\Drupal::time()->getRequestTime(), 'long'), ]) . '

', ], ]; return $build; }

The first item in the #lazy_builder array is the callback to invoke (here, a static method reference); the second is the array of arguments to hand it. Notice lazyBuilder() is static and receives no injected services — that's not a style choice, it's a requirement.

Why arguments must be primitives only

A #lazy_builder callback's arguments can only be primitive types — strings, booleans, integers, floats, or NULL, never objects. This isn't an arbitrary restriction: a lazy-built placeholder might get resolved during a completely separate, later request than the one that defined it (this is exactly how BigPipe streams a page in multiple passes). By the time the callback actually runs, the object that created the placeholder may no longer exist in memory at all — only serializable, storable primitives survive that gap reliably. That's why the example passes $this->currentUser->id() (an integer) rather than the whole user object.

#create_placeholder — forcing deferral

'#create_placeholder' => TRUE,

Without #create_placeholder, Drupal decides for itself, based on the surrounding render tree's cache metadata, whether a #lazy_builder element should be resolved immediately or deferred as a placeholder — typically it only auto-placeholders when high-cardinality cache contexts (like user) are present. Setting #create_placeholder => TRUE forces the deferral explicitly, useful when you know in advance that a piece of the form is expensive enough to be worth deferring even without that automatic signal.

Why this matters for forms specifically: without #lazy_builder, a single genuinely uncacheable element anywhere in a render tree makes the whole surrounding tree uncacheable too — one clock widget can poison caching for an entire complex form. A lazy-built placeholder isolates that uncacheable piece so everything around it can still be cached and reused.

Quick check: you want a #lazy_builder callback to look up and display a specific node's title. Which is the correct argument to pass — the loaded Node entity object, or the node's integer ID? (Answer: the integer ID. Load the entity fresh inside the callback. Passing the object directly violates the primitives-only rule, since the callback may run in a different request context where that in-memory object no longer exists.)

Key takeaways

  • #lazy_builder defers building a render array element until it's actually needed, via a callback plus an array of primitive-only arguments.
  • Callback arguments must be strings, booleans, integers, floats, or NULL — never objects — because the callback may run in a separate later request (as with BigPipe's multi-pass rendering).
  • Load any needed entities fresh inside the callback using a passed-in ID, rather than trying to pass the entity object itself.
  • #create_placeholder => TRUE explicitly forces deferral rather than relying on Drupal's automatic cache-context-based heuristic.
  • Isolating one uncacheable element behind a lazy builder lets the rest of a complex form's render tree stay cacheable.

Coming up next

You've now covered the full arc of form performance: caching, trimming, AJAX efficiency, and deferred rendering. Next, we shift from making forms fast to proving they actually work correctly — starting with how to write real, automated unit tests for your form logic in Writing Unit Tests for Forms.