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_buildercallback actually defers, and why that matters for caching - The strict argument-type rule
#lazy_buildercallbacks must follow, and why it exists - How
#create_placeholderworks 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.
#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_buildercallback to look up and display a specific node's title. Which is the correct argument to pass — the loadedNodeentity 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_builderdefers 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 => TRUEexplicitly 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.