Adding cache tags and contexts to formsfor Drupal 8 , 9 , 10 , and 11

Last updated :  

Think of a busy restaurant kitchen that re-cooks a dish from scratch for every single order, even when fifty people ordered the exact same thing. That's what an uncached form does every time it renders — Drupal rebuilds every render array element from nothing on every request. Cache tags and contexts are how you tell the kitchen "keep a batch of this ready, and here's exactly when you need to throw it out and start fresh."

What you'll learn in this lesson

  • The real difference between a cache tag and a cache context, and why forms need both concepts
  • How to attach #cache metadata to a form render array
  • Why forms are usually only partially cacheable, not wholesale cacheable

Cache tags vs. cache contexts — two different jobs

These two terms get confused constantly, so it's worth being precise: a cache tag answers "when does this cached data go stale?" — it's an invalidation trigger, like node:42, that Drupal fires whenever that specific thing changes. A cache context answers a completely different question: "does this cached data even mean the same thing for every visitor?" — it's a variation key, like user or languages:language_interface, that tells Drupal to keep separate cached copies per context value rather than serving one visitor's cached output to another.

$build['cache_demonstration'] = [
  '#markup' => $this->t('Hello @name, welcome to the #cache example.', ['@name' => $this->currentUser->getAccountName()]),
  '#cache' => [
    'contexts' => [
      'user',
    ],
  ],
];

This is the real #cache pattern from Drupal's own Render Example module. Notice the string embeds the current user's name — without the user context declared, the first visitor's name would get baked into a cached copy that every subsequent visitor would then see. Declaring the context tells Drupal's render cache: "store a separate copy of this element for every distinct user."

Why a whole form is rarely one cacheable unit

Here's the part that trips people up: you almost never put #cache on the outer $form array itself. A form contains a CSRF token, default values pulled from the current user's data, and validation state — all of that is inherently request-specific and uncacheable. What you actually cache are the static, expensive-to-build pieces inside the form: a long list of options fetched from an external API, a rendered help block, a complex nested fieldset whose content doesn't depend on who's looking at it.

$form['expensive_options_list'] = [
  '#type' => 'select',
  '#title' => $this->t('Choose a region'),
  '#options' => $this->buildExpensiveOptionsList(),
  '#cache' => [
    'tags' => ['region_list'],
    'contexts' => ['languages:language_interface'],
  ],
];

Tag it with region_list so that whatever code updates the region list also calls Cache::invalidateTags(['region_list']) to bust this exact cached fragment — no need to clear the whole page cache for an unrelated change elsewhere on the site.

Common mistake: caching a form element that reads from $form_state or reflects a just-submitted value. Anything derived from the current request's mutable state is, by definition, not safe to reuse for the next request — cache only what would legitimately look the same to the next visitor who has the same cache context values.

Quick check: you cache a "recommended products" fieldset inside a form with 'contexts' => ['user.roles']. A site has 40,000 individual users but only 4 roles. How many separate cached copies of that fieldset will Drupal end up storing? (Answer: at most 4 — one per unique combination of roles, not one per user. This is exactly why user.roles is far cheaper than the more specific user context when role is all that actually matters.)

Key takeaways

  • Cache tags control when cached data expires; cache contexts control how many variants of it get stored.
  • Almost never cache the outer $form array as a whole — target the specific expensive, request-independent pieces inside it.
  • Always pair a cache tag with the code path that actually invalidates it, or the cached data will silently go stale.
  • Prefer the least specific cache context that's still correct — user.roles over user whenever role, not identity, is what actually varies the output.

Coming up next

Caching handles data that's expensive to compute but doesn't change often. The next lesson tackles a different performance lever entirely: trimming down the form itself so there's simply less to build, render, and submit in the first place — Optimizing Form Element Rendering.