From drupal-skills
Apply correct cache metadata (tags, contexts, max-age) to Drupal render arrays and implement cache invalidation patterns. Use WHENEVER producing render arrays that display entity or config data, working with blocks that need cache metadata, or troubleshooting stale content. Covers #cache on render arrays, getCacheTags()/getCacheContexts() on blocks, cache tag invalidation, and cache bubbling behavior. Do NOT use for building templates or themed output structure (use drupal-theming).
How this skill is triggered — by the user, by Claude, or both
Slash command
/drupal-skills:drupal-cachingThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Cache metadata (tags, contexts, max-age) is stable across Drupal 10 and 11. No syntax differences between versions.
Cache metadata (tags, contexts, max-age) is stable across Drupal 10 and 11. No syntax differences between versions.
Before returning any render array, ask these three questions:
$build = [
'#theme' => 'my_module_output',
'#data' => $data,
'#cache' => [
'tags' => ['node:5', 'config:my_module.settings'],
'contexts' => ['user.roles', 'url.path'],
'max-age' => \Drupal\Core\Cache\Cache::PERMANENT,
],
];
WRONG: Omitting
#cacheon render arrays. Render arrays without cache metadata become stale after the underlying data changes. Caching is invisible during development (usually disabled) but critical in production. Every render array that displays entity or config data MUST have#cachewith appropriate tags, contexts, and max-age. RIGHT: Always add'#cache' => ['tags' => [...], 'contexts' => [...], 'max-age' => ...]to every render array. Use$entity->getCacheTags()and$config->getCacheTags()to get tags from dependent objects.
Cache tags are strings that mark what data a render array depends on. When that data changes, all render arrays with matching tags are invalidated automatically.
// Single entity: tag is "entity_type:id"
$tags = $node->getCacheTags(); // Returns ['node:5']
// Use entity's own tags -- never hardcode
$build['#cache']['tags'] = $node->getCacheTags();
// List tag: invalidated when ANY entity of that type is created/updated/deleted
$tags = $node_type->getListCacheTags(); // Returns ['node_list']
// Use for listings where you don't know which entities appear
$build['#cache']['tags'] = ['node_list'];
// Config objects provide their own tags
$config = \Drupal::config('my_module.settings');
$tags = $config->getCacheTags(); // Returns ['config:my_module.settings']
$build['#cache']['tags'] = $config->getCacheTags();
// Define custom tags for custom data
$build['#cache']['tags'] = ['my_module:custom_data'];
// Invalidate when your custom data changes
\Drupal\Core\Cache\Cache::invalidateTags(['my_module:custom_data']);
Tags are ADDITIVE -- use all that apply. Merge when combining from multiple objects:
use Drupal\Core\Cache\Cache;
$tags = Cache::mergeTags(
$node->getCacheTags(), // ['node:5']
$config->getCacheTags() // ['config:my_module.settings']
);
// Result: ['node:5', 'config:my_module.settings']
$build['#cache']['tags'] = $tags;
Cache contexts tell Drupal to store separate cached versions based on runtime conditions. Contexts BUBBLE UP from child render arrays to the page level.
| Context | Varies by | Use when |
|---|---|---|
user | Individual user | Content is unique per user (high cardinality -- see lazy builders) |
user.roles | Role combination | Content differs by role (admin vs editor vs anonymous) |
user.permissions | Permission set | Content differs by specific permissions (more granular than roles) |
url.path | Current URL path | Content depends on which page it appears on |
url.query_args | Query string | Content depends on query parameters (filters, pagination) |
languages | Active language | Content varies by site language |
route | Current route | Content differs by route name |
Contexts are hierarchical: user encompasses user.roles, which encompasses user.permissions. Use the most specific context that applies.
// Render array that shows different content per role
$build = [
'#markup' => $role_specific_message,
'#cache' => [
'tags' => $config->getCacheTags(),
'contexts' => ['user.roles'],
],
];
| Value | Meaning | Use when |
|---|---|---|
Cache::PERMANENT | Cached until tags invalidate it | Default. Data has proper cache tags for invalidation |
3600 | Cached for 1 hour | External data without invalidation tags (API responses) |
0 | NEVER cache | Highly dynamic content -- but prefer lazy builders instead |
WRONG: Setting
max-ageto0without understanding consequences.max-ageof0bubbles up to the PAGE level, preventing the entire page from being cached by Dynamic Page Cache. This degrades performance for the whole page, not just your component. RIGHT: Use lazy builders (#lazy_builder) to isolate uncacheable content. The lazy-built portion is replaced at render time while the rest of the page remains fully cached. Only usemax-ageof0when the entire controller response is truly uncacheable.
WRONG: Relying on
max-ageof0for anonymous users. Internal Page Cache ignores bubbledmax-age. Anonymous users will see stale content even withmax-ageset to0. This is a real Drupal bug trap. RIGHT: For truly uncacheable anonymous content, use the page cache kill switch service:\Drupal::service('page_cache_kill_switch')->trigger(). This prevents Internal Page Cache from caching the response.
When a small part of the page is dynamic, use #lazy_builder instead of max-age of 0. The rest of the page remains cacheable while the lazy-built content is replaced at render time via placeholders.
// In your block's build() method:
public function build() {
return [
'#lazy_builder' => [
'my_module.lazy_builder:renderDynamicContent', // service:method
[$entity_id], // scalar arguments only
],
'#create_placeholder' => TRUE,
];
}
# my_module.services.yml
services:
my_module.lazy_builder:
class: Drupal\my_module\MyModuleLazyBuilder
arguments: ['@entity_type.manager']
namespace Drupal\my_module;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Security\TrustedCallbackInterface;
class MyModuleLazyBuilder implements TrustedCallbackInterface {
protected $entityTypeManager;
public function __construct(EntityTypeManagerInterface $entity_type_manager) {
$this->entityTypeManager = $entity_type_manager;
}
public static function trustedCallbacks() {
return ['renderDynamicContent'];
}
public function renderDynamicContent($entity_id) {
$entity = $this->entityTypeManager->getStorage('node')->load($entity_id);
return [
'#markup' => $entity->label(),
'#cache' => [
'tags' => $entity->getCacheTags(),
'max-age' => 0,
],
];
}
}
WRONG: Passing non-scalar arguments to lazy builders. Lazy builder arguments must be JSON-serializable scalars (strings, numbers, booleans). Passing objects or arrays causes fatal errors. RIGHT: Pass entity IDs (integers), configuration keys (strings), or boolean flags. Load the actual objects inside the lazy builder callback using injected services.
Drupal automatically converts render arrays with certain cache properties into placeholders without you needing #lazy_builder. This happens when:
max-age is 0session or user (high cardinality)The auto-placeholder conditions are configurable via the renderer.config service parameter. However, explicit #lazy_builder with #create_placeholder is preferred for clarity.
Implement on custom value objects, response objects, or any class that carries cache metadata.
use Drupal\Core\Cache\CacheableDependencyInterface;
use Drupal\Core\Cache\Cache;
class MyDataValue implements CacheableDependencyInterface {
public function getCacheTags() {
return ['my_module:data'];
}
public function getCacheContexts() {
return ['user.roles'];
}
public function getCacheMaxAge() {
return Cache::PERMANENT;
}
}
use Drupal\Core\Cache\CacheableMetadata;
// Create from a render array
$metadata = CacheableMetadata::createFromRenderArray($build);
// Create from any CacheableDependencyInterface object
$metadata = CacheableMetadata::createFromObject($entity);
// Merge metadata from multiple sources
$metadata->addCacheableDependency($config);
$metadata->addCacheContexts(['user.roles']);
$metadata->addCacheTags(['node_list']);
// Apply merged metadata back to a render array
$metadata->applyTo($build);
AccessResult already implements CacheableDependencyInterface -- access results carry cache tags and contexts that affect the render pipeline.
When a controller loads multiple entities, aggregate their cache metadata into a single CacheableMetadata object and apply it to the response.
Render array controller pattern:
public function list() {
$build = ['#theme' => 'my_list', '#items' => []];
$cache_metadata = new CacheableMetadata();
$cache_metadata->addCacheTags(['my_entity_list']);
$cache_metadata->addCacheContexts(['user.permissions']);
$entities = $this->entityTypeManager->getStorage('my_entity')
->loadMultiple();
foreach ($entities as $entity) {
$cache_metadata->addCacheableDependency($entity);
$build['#items'][] = [...];
}
$cache_metadata->applyTo($build);
return $build;
}
JSON controller pattern:
public function apiList() {
$data = [];
$cache_metadata = new CacheableMetadata();
$cache_metadata->addCacheTags(['my_entity_list']);
$cache_metadata->addCacheContexts(['user.permissions']);
foreach ($this->entityTypeManager->getStorage('my_entity')->loadMultiple() as $entity) {
$cache_metadata->addCacheableDependency($entity);
$data[] = $this->serialize($entity);
}
$response = new CacheableJsonResponse($data);
$response->addCacheableDependency($cache_metadata);
return $response;
}
WRONG: Loading multiple entities but only adding cache tags for the first, or hardcoding generic tags like
['node_list']without per-entity tags. Changes to specific entities do not invalidate the response. RIGHT: CalladdCacheableDependency($entity)for EACH entity in the loop. This captures both individual entity tags AND entity type list tags automatically.
Block plugins extend BlockBase, which implements CacheableDependencyInterface. Override the cache methods to declare your block's caching requirements.
use Drupal\Core\Block\BlockBase;
use Drupal\Core\Cache\Cache;
class MyBlock extends BlockBase {
public function build() {
return [
'#markup' => $this->t('Dynamic content'),
'#cache' => [
'tags' => $this->getEntity()->getCacheTags(),
],
];
}
public function getCacheContexts() {
// Always merge with parent -- parent may have contexts from block config
return Cache::mergeContexts(parent::getCacheContexts(), ['user.roles']);
}
public function getCacheTags() {
return Cache::mergeTags(parent::getCacheTags(), ['node_list']);
}
public function getCacheMaxAge() {
return Cache::PERMANENT;
}
}
WRONG: Overriding
getCacheContexts()orgetCacheTags()without merging with parent values. The parent block class may have contexts or tags from block configuration that you lose if you replace them entirely. RIGHT: Always useCache::mergeContexts(parent::getCacheContexts(), ['your.context'])andCache::mergeTags(parent::getCacheTags(), ['your_tag'])to combine with parent values.
Use the Cache API directly when you need to cache expensive computations, external API responses, or aggregated data that doesn't fit into render arrays.
// Get the default cache bin
$cache = \Drupal::cache(); // Or inject 'cache.default' service
// Get a specific bin
$cache = \Drupal::cache('data');
// Read
$cached = $cache->get('my_module:expensive_result');
if ($cached) {
$data = $cached->data;
}
else {
$data = $this->computeExpensiveResult();
$cache->set('my_module:expensive_result', $data, \Drupal\Core\Cache\CacheBackendInterface::CACHE_PERMANENT, ['node_list']);
}
// Invalidate by tags (across ALL bins)
Cache::invalidateTags(['my_module:custom_tag']);
// Delete specific entry
$cache->delete('my_module:expensive_result');
Common cache bins: default, render, page, discovery, data. Tags on cache entries enable automatic invalidation via Cache::invalidateTags().
WRONG: Clearing entire cache bins to invalidate specific data. Deleting all entries in a bin is destructive and unnecessary. Drupal's tag-based invalidation automatically finds and invalidates only the affected entries across all bins. RIGHT: Use
Cache::invalidateTags(['my_tag'])to invalidate specific entries. Under the hood, this uses thecache_tags.invalidatorservice. Inject that service rather than using the static call when possible.
Drupal has TWO separate page caching systems with different behaviors. Understanding both is critical for correct caching.
| Feature | Internal Page Cache | Dynamic Page Cache |
|---|---|---|
| Serves | Anonymous users only | All users (including authenticated) |
| Caches | Full HTTP response | Individual render arrays |
| Respects max-age | NO (ignores bubbled max-age) | YES |
| Respects tags | YES (invalidation works) | YES |
| Respects contexts | NO (same response for all anonymous) | YES (varies by context) |
| Kill switch | page_cache_kill_switch service | Set max-age to 0 |
WRONG: Assuming
max-ageof0prevents anonymous caching. Internal Page Cache does NOT respect bubbledmax-age. If an anonymous user visits a page first, ALL subsequent anonymous users see that cached version regardless ofmax-agesettings on child render arrays. RIGHT: For pages that must not be cached for anonymous users, use\Drupal::service('page_cache_kill_switch')->trigger()in your controller or event subscriber. This bypasses Internal Page Cache entirely.
max-age of 0 inside the lazy-built render array.page_cache_kill_switch service.See also: drupal-theming (if installed) for render array structure and #theme patterns. Cache metadata goes on EVERY render array the theming skill teaches you to build. If not available, add '#cache' => ['tags' => [...], 'contexts' => [...], 'max-age' => ...] to all render arrays.
See also: drupal-access-security (if installed) for AccessResult cache metadata. Access results carry cache tags and contexts that affect the render pipeline. Use $access->addCacheableDependency($config) and $access->addCacheContexts(['user.roles']). If not available, always add cache metadata to AccessResult objects via addCacheableDependency() and addCacheContexts().
See also: drupal-plugins-blocks (if installed) for block plugin caching overrides (getCacheContexts(), getCacheTags()). Blocks extend BlockBase which implements CacheableDependencyInterface. If not available, override getCacheContexts() and getCacheTags() on BlockBase subclasses, always merging with parent values.
See also: drupal-entities-fields (if installed) for entity cache tags via getCacheTags() and list cache tags via getListCacheTags(). Entities automatically implement CacheableDependencyInterface. If not available, use $entity->getCacheTags() for individual entity tags and the entity_type_list pattern (e.g., node_list) for list invalidation.
npx claudepluginhub proofoftom/drupal-skills --plugin drupal-skillsProvides behavioral guidelines to reduce common LLM coding mistakes, focusing on simplicity, surgical changes, assumption surfacing, and verifiable success criteria.
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
Creates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.