Recently I wrote about launching my wife's web store for her holiday cookie orders. During the launch, I had a little hiccup. The homepage controller has code to display the available published products. When it came time, I published the products, and... the homepage stayed the same. I was confused. I wrote the code and knew it was collecting cacheable metadata. It should have been updated once changes happened to the products. So, I did what all Drupalists do when things don't work as expected: clear the cache. Then I needed to figure out why this didn't work so that the homepage would be updated once we unpublished the products.
Drupal's caching strategies revolve around cache contexts and tags. Cache contexts provide a cardinality on the cache, allowing unique cache entries per user or cache entries based on the current site language. Cache tags enable cache entries to be invalidated, allowing for the processing and creating of a new cache entry. When rendered output in Drupal has cache tags and associated contexts, we call it cache metadata. In my case, the homepage not updating showed I hadn't used the correct cache tags. I wrote about how Drupal uses cache tags in a previous post, "Drupal: cache tags for all, regardless your backend," if you want to learn more.
Here's the code in question. I performed a query to get all available products.
$product_storage = $this->entityTypeManager->getStorage('commerce_product'); $product_ids = $product_storage->getQuery() // Using access checks will exclude unpublished products. ->accessCheck(TRUE) ->sort('product_id', 'DESC') ->execute(); $products = $product_storage->loadMultiple($product_ids);
I then collected their cacheable metadata to apply it to my build output for the controller. This code takes a new
CacheableMetadata object and adds each product as a cacheable dependency. Entity objects implement
\Drupal\Core\Cache\CacheableDependencyInterface, which means they return information for their caching: cache tags, cache contexts, and cache maximum age (defaults to permanent.) Calling
addCacheableDependency merges the product's cache tags, cache contexts, and max age. Calling
\Drupal\Core\Cache\CacheableMetadata::applyTo adds the aggregated cache information to the render array so that the cached output is tagged correctly.
$cacheability = array_reduce( $products, static fn ( CacheableMetadata $metadata, ProductInterface $product) => $metadata->addCacheableDependency($product), new CacheableMetadata() ); // $build is an existing render array. $cacheability->applyTo($build);
There is a significant flaw in my logic. This collects cacheable metadata for any products returned from my entity query. That means creating a new product will not invalidate the cache. It only means that products displayed when the cached output was made would invalidate the cache whenever modified and saved. I need this cache to be invalidated whenever any product is saved. I want it to show new and remove old products as needed.
Luckily, entity types have a generic array of "list" cache tags, which can be retrieved via
/** * The list cache tags associated with this entity type. * * Enables code listing entities of this type to ensure that newly created * entities show up immediately. * * @return string */ public function getListCacheTags();
Whenever an entity is saved, this cache tag is invalidated. That allows for proper invalidation of cached output, which lists entities of that entity type. Since this cache tag can have far-reaching effects on cache invalidation, it is not automatically used when adding an entity as a cacheable dependency.
I went too granular when all I truly needed was to merge these cache tags:
$cacheability->addCacheTags( $product_storage->getEntityType()->getListCacheTags() );
Once I made this change, the homepage showed the correct products whenever we published or unpublished a product! So, if you are programmatically listing entities, make sure you include the list cache tags for that entity type.