Every controller method you've seen so far has returned an array — never a raw HTML string. That's not a style preference; it's the whole reason Drupal's output stays flexible, cacheable, and themeable. A render array describes what should appear on the page without committing to exactly how it becomes HTML, which means other code (themes, alter hooks, the caching layer) can still influence the result after your controller has already run.
What you'll learn in this lesson
- The difference between
#markupand#theme-based output - Why late rendering matters, using a real "before and after" example
- A few of the most common
#-prefixed keys you'll reach for
The simplest render array: #markup
public function simple() {
return [
'#markup' => $this->t('Simple page: The quick brown fox jumps over the lazy dog.'),
];
}
#markup is the most direct way to output a string of already-formed HTML. It's automatically run through Drupal's XSS filter, so it's safe even if the string contains untrusted content — but it's a dead end for further processing. Once you hand Drupal a markup string, nothing else can meaningfully restructure it.
A more structured alternative: #theme
public function arguments($first, $second) {
$list[] = $this->t('First number was @number.', ['@number' => $first]);
$list[] = $this->t('Second number was @number.', ['@number' => $second]);
$list[] = $this->t('The total was @number.', ['@number' => $first + $second]);
return [
'#theme' => 'item_list',
'#items' => $list,
'#title' => $this->t('Argument Information'),
];
}
Here, instead of building a <ul> string by hand, #theme => 'item_list' delegates that job to a theme hook built into Drupal core specifically for rendering lists. #items supplies the raw data — an array of strings — and #title becomes the heading above it. The actual HTML doesn't get built until the very last moment Drupal needs it.
Why "late rendering" actually matters
This is the part that's easy to skim past: keeping content as structured data (an array, in this case) rather than a pre-built string means a theme can override exactly how an item_list renders — site-wide, without ever touching your controller's PHP — and any code with an alter hook for that theme hook still gets a chance to modify #items or #title before the HTML exists. Hand Drupal a raw #markup string instead, and none of that late-stage flexibility is available; what you wrote is what ships.
#markup only for genuinely simple, one-off output. The moment you're building a list, a table, or anything with real structure, a #theme-based render array keeps your output themeable and alterable in ways a markup string never will be.Quick check: a contributed theme wants to change how every list on the site is wrapped and styled, without touching any module's PHP. Is that possible if a controller used
#theme => 'item_list'? Yes — the theme can override theitem-list.html.twigtemplate site-wide, and every controller using that theme hook picks up the change automatically. It would not be possible if the controller had used#markupinstead.
Key takeaways
- A controller must always return a render array — an associative array describing content, never a raw HTML string on its own.
#markupis for simple, already-formed HTML strings; it's automatically XSS-filtered but offers no further structural flexibility.#themedelegates rendering to a named theme hook (like the coreitem_listhook), keeping content as structured data until the last possible moment.- Late rendering is what lets themes and alter hooks change output after your controller runs — a benefit only available to
#theme-based arrays, not#markup.
Coming up next
Render arrays interact with one more system you'll want to understand well: Drupal's cache layer. The next lesson, Adding Cache Metadata to Controllers, covers how to tell Drupal exactly when a piece of output is safe to cache and when it isn't.