$npx -y skills add edutrul/drupal-ai --skill drupal-renderDrupal render arrays — structure, cache metadata (tags, contexts, max-age), render elements, and markup safety.
| 1 | # Drupal Render Arrays |
| 2 | |
| 3 | ## Basic Structure |
| 4 | |
| 5 | ```php |
| 6 | $build = [ |
| 7 | '#type' => 'container', |
| 8 | '#attributes' => ['class' => ['my-wrapper']], |
| 9 | 'content' => [ |
| 10 | '#markup' => $this->t('Hello world'), |
| 11 | ], |
| 12 | ]; |
| 13 | ``` |
| 14 | |
| 15 | ## Cache Metadata (Always Required) |
| 16 | |
| 17 | ```php |
| 18 | $build['content'] = [ |
| 19 | '#markup' => $output, |
| 20 | '#cache' => [ |
| 21 | 'tags' => ['node:' . $node->id(), 'node_list'], |
| 22 | 'contexts' => ['user.permissions', 'url.query_args'], |
| 23 | 'max-age' => 3600, |
| 24 | ], |
| 25 | ]; |
| 26 | ``` |
| 27 | |
| 28 | ### Cache Tag Conventions |
| 29 | |
| 30 | | Tag | Invalidates when | |
| 31 | |---|---| |
| 32 | | `node:123` | Node 123 is saved | |
| 33 | | `node_list` | Any node is created/deleted | |
| 34 | | `user:456` | User 456 is saved | |
| 35 | | `taxonomy_term:789` | Term 789 is saved | |
| 36 | | `config:my_module.settings` | Config is saved | |
| 37 | | `block_content:1` | Block content is saved | |
| 38 | |
| 39 | ### Cache Contexts |
| 40 | |
| 41 | | Context | Varies by | |
| 42 | |---|---| |
| 43 | | `user` | Current user identity | |
| 44 | | `user.permissions` | User's permissions | |
| 45 | | `user.roles` | User's roles | |
| 46 | | `url` | Full URL | |
| 47 | | `url.path` | URL path only | |
| 48 | | `url.query_args` | Query string | |
| 49 | | `languages` | Current language | |
| 50 | | `session` | Session data | |
| 51 | | `theme` | Active theme | |
| 52 | |
| 53 | ## Markup Safety |
| 54 | |
| 55 | ```php |
| 56 | // Plain text — always safe |
| 57 | $build['#plain_text'] = $user_input; |
| 58 | |
| 59 | // Trusted markup — use Markup class |
| 60 | use Drupal\Core\Render\Markup; |
| 61 | $build['#markup'] = Markup::create('<strong>Safe</strong>'); |
| 62 | |
| 63 | // User input in markup — filter first |
| 64 | use Drupal\Component\Utility\Xss; |
| 65 | $build['#markup'] = Markup::create(Xss::filter($user_input)); |
| 66 | |
| 67 | // Admin-level markup — allows more tags |
| 68 | $build['#markup'] = Markup::create(Xss::filterAdmin($html)); |
| 69 | ``` |
| 70 | |
| 71 | ## Render Elements |
| 72 | |
| 73 | ```php |
| 74 | // HTML tag |
| 75 | $build['heading'] = [ |
| 76 | '#type' => 'html_tag', |
| 77 | '#tag' => 'h2', |
| 78 | '#value' => $this->t('Title'), |
| 79 | '#attributes' => ['class' => ['my-heading']], |
| 80 | ]; |
| 81 | |
| 82 | // Link |
| 83 | $build['link'] = [ |
| 84 | '#type' => 'link', |
| 85 | '#title' => $this->t('Go'), |
| 86 | '#url' => Url::fromRoute('entity.node.canonical', ['node' => $nid]), |
| 87 | '#attributes' => ['class' => ['button']], |
| 88 | ]; |
| 89 | |
| 90 | // Entity view |
| 91 | $build['node'] = $this->entityTypeManager |
| 92 | ->getViewBuilder('node') |
| 93 | ->view($node, 'teaser'); |
| 94 | |
| 95 | // Inline template |
| 96 | $build['inline'] = [ |
| 97 | '#type' => 'inline_template', |
| 98 | '#template' => '<div class="{{ class }}">{{ content }}</div>', |
| 99 | '#context' => ['class' => 'my-class', 'content' => $content], |
| 100 | ]; |
| 101 | ``` |
| 102 | |
| 103 | ## Lazy Builder (Uncacheable Content) |
| 104 | |
| 105 | ```php |
| 106 | $build['dynamic'] = [ |
| 107 | '#lazy_builder' => [ |
| 108 | 'my_module.service:buildDynamicContent', |
| 109 | [$arg1, $arg2], |
| 110 | ], |
| 111 | '#create_placeholder' => TRUE, |
| 112 | ]; |
| 113 | ``` |
| 114 | |
| 115 | ## Rendering Programmatically |
| 116 | |
| 117 | ```php |
| 118 | // Inject renderer service |
| 119 | public function __construct( |
| 120 | private readonly RendererInterface $renderer, |
| 121 | ) {} |
| 122 | |
| 123 | // Render to string |
| 124 | $html = $this->renderer->render($build); |
| 125 | |
| 126 | // Render in isolated context (doesn't bubble cache) |
| 127 | $html = $this->renderer->renderInIsolation($build); |
| 128 | ``` |