Drupal preprocess hooks: Why cache context matters
My team modified the preprocess hook for the branding block on a Drupal website to show different logos with different colors on different pages. Instead, our QA team found the same logo everywhere. The issue? We forgot to add a cache context to our preprocess hook. This is a common mistake that can slip through code reviews, but it's easy to fix once you understand what's happening.
What are preprocess hooks?
Preprocess hooks in Drupal are functions that allow you to modify variables before a template is rendered. Think of them as a way to customize what data gets passed to your Twig templates. They're incredibly powerful. You can alter block content, node displays, and other renderable elements with ease.
Here's a simple example:
function mymodule_preprocess_block(&$variables) {
// Modify block variables before template rendering
$variables['custom_message'] = 'Hello from preprocess!';
}
Most common preprocess hooks
Here are the preprocess hooks you'll encounter most often:
hook_preprocess_block()
: Modify block variables before rendering. Very common for customizing blocks, menus, and other block-based content.hook_preprocess_node()
: Alter node variables before the node template renders. Used for customizing how content types display.hook_preprocess_page()
: Modify page-level variables. Often used for adding custom CSS classes, modifying page titles, or adding page-specific content.hook_preprocess_html()
: Alter HTML document variables. Used for adding classes to the <html> tag, modifying the document title, or adding meta tags.hook_preprocess_menu()
: Customize menu rendering. Useful for adding custom classes, modifying menu items, or changing menu structure.hook_preprocess_field()
: Modify field variables before field templates render. Common for customizing how specific fields display across content types.hook_preprocess_views_view()
: Alter Views variables before rendering. Used for customizing Views output, adding custom classes, or modifying View data.hook_preprocess_form()
: Modify form variables before form templates render. Useful for adding custom classes, modifying form elements, or adding form-specific content.
Each of these hooks follows the same pattern: they receive a &$variables
array that you can modify to change what gets passed to the template.
Modern approach: Object-oriented preprocess hooks (Drupal 10.3.0+)
Starting with Drupal 10.3.0, you can implement preprocess hooks using an object-oriented approach with PHP attributes. This is the recommended modern method:
use Drupal\Core\Hook\Attribute\Preprocess;
class MyModulePreprocess {
#[Preprocess('block')]
public function preprocessBlock(array &$variables): void {
// Your preprocessing logic here
$variables['custom_message'] = 'Hello from modern preprocess!';
}
#[Preprocess('node')]
public function preprocessNode(array &$variables): void {
// Node preprocessing logic
}
}
The traditional procedural approach still works. It's fully supported. However, the object-oriented method offers better code organization, dependency injection, and aligns with modern Drupal practices.
What are cache contexts?
Cache contexts tell Drupal's render cache system when output should vary. They're like instructions. They say "this content changes based on X, so create separate cache entries for each variation of X."
Common cache contexts include:
url.path
: varies by the current URL pathroute
: varies by the current routeuser
: varies by the current userlanguages:language_interface
: varies by language
The real problem we encountered
Our team built a feature to show different branding logos with different colors on blog pages versus other pages. Here's an example with similar logic (I can't share the actual client code):
/**
* @file
* Custom experiments module for Drupal CMS.
*/
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Cache\CacheableMetadata;
/**
* Implements hook_preprocess_HOOK() for block template.
*/
function custom_experiments_preprocess_block__system_branding_block(array &$variables) {
// Get the current route match
$route_match = \Drupal::routeMatch();
$route_name = $route_match->getRouteName();
// Create cacheable metadata for safe cache context management
$cacheable_metadata = new CacheableMetadata();
$cacheable_metadata->addCacheContexts(['url.path']);
// Check if we're on a blog page
if ($route_name === 'entity.node.canonical') {
$node = $route_match->getParameter('node');
if ($node && $node->bundle() === 'blog') {
// On blog pages, modify the site name to show "My Updated Site Name"
$variables['site_name'] = 'My Updated Site Name';
// Add node cache tags for proper cache invalidation
$cacheable_metadata->addCacheTags($node->getCacheTags());
}
}
// Safely merge cacheable metadata
$cacheable_metadata->applyTo($variables);
}
Without adding the cache context via the $cacheable_metadata->addCacheContexts()
method, Drupal would cache the first version of the block it renders (e.g., the one on a non-blog page) and incorrectly reuse it on every other page. That's why our QA team found the same branding logo everywhere.
Why this happens
- Render caching: Drupal caches render arrays for performance.
- Varying output: If output changes by path or route, the cache key must include that variability.
- Cache contexts: Adding
url.path
(or a more specific context) tells Drupal to maintain separate cache variations per path.
Is this true for all preprocess hooks?
Yes, the rule applies to any renderable output that varies. Preprocess hooks often modify render arrays or template variables that become part of the render cache. If your logic varies by:
- Path or route → add
url.path
orroute
- Current user → add
user
(or more granular contexts likeuser.roles
) - Language → add
languages:language_interface
- Theme or breakpoint → add
theme
,responsive_image_style
, etc.
Preprocess itself is not special; what matters is whether the resulting render array should be cached differently across requests. If the output varies, declare the right cache contexts.
Choosing the right cache context
- Prefer precision: If you depend on the canonical node, consider
route
instead ofurl.path
to avoid unnecessary cache fragmentation (e.g., query strings or aliases). - Entity-driven blocks: When you depend on an entity (like a node), add cache dependencies so edits purge correctly:
$variables['#cache']['tags'][] = 'node:' . $node->id();
- Max-age: Keep
max-age
at default (permanent) unless output truly expires.
Other useful cache contexts
route
: vary by route name (often smaller cache thanurl.path
).url.path
: vary by the full path (most specific; larger cache).url.query_args
orurl.query_args:foo
: vary by all or specific query parameters.user
,user.roles
,user.permissions
: vary by current user state.languages:language_interface
,languages:language_content
: vary by language.theme
,timezone
,request_format
: vary by theme or request format.headers:User-Agent
,cookies
: advanced; use sparingly to avoid cache fragmentation.
How to catch this in code reviews
This issue slipped through our PR review process, but it shouldn't have. Here's what reviewers should look for:
- Route-dependent logic: Any preprocess hook that checks
$route_match->getRouteName()
or$route_match->getParameter()
needs cache contexts - Path-dependent logic: Code that uses
\Drupal::request()->getPathInfo()
or similar needsurl.path
context - User-dependent logic: Any code that checks
\Drupal::currentUser()
needs user-related contexts - Entity-dependent logic: Code that loads or checks entities needs cache tags for those entities
Review checklist for preprocess hooks:
- Does this hook modify output based on the current request?
- If yes, what cache contexts are needed?
- Are cache tags included for any entities being used?
- Is the cache context granular enough (not too broad, not too narrow)?
Testing cache contexts
To verify your cache contexts work correctly:
- Clear all caches after implementing your preprocess hook
- Visit different pages that should show different content
- Check the HTML source to ensure content varies as expected
- Use Drupal's cache debug tools to inspect cache keys and contexts
If you see the same content on different pages after clearing cache, you're missing a cache context.
Checklist
- Does the output change by path or route? Add
url.path
orroute
. - Does it depend on an entity? Add cache tags for that entity.
- Does it vary by user or language? Add the matching contexts.
- Keep
max-age
high; rely on tags for invalidation.
Cache contexts are essential when your preprocess logic creates varying output. Always consider cache contexts during development and make them part of your code review process.