Invalidating Drupal 8+ cache items by owner

3 minute read

One of the key enhancements in Drupal 8+ over prior versions is the Cache API. The concept of cache tags and contexts is one of Drupal’s great contributions to the web ecosystem and is, in my humble opinion, best-in-class.

That said, the out-of-the-box implementation isn’t perfect. Especially if you implement capital-V views (that is, views from Views module) and/or have frequent invalidation, some customization of your architecture and implementation are in order.

As a dating site, Meet Kinksters has caching considerations unlike brochureware or e-commerce installs. Since the majority of our content is user-generated, we cannot make assumptions about the frequency or nature of invalidations, but rather must plan to cache the data that provides us the most significant performance improvement. Importantly, this means changing our caching paradigm away from rendered content (this is relatively cheap to generate) but rather data which is accessed in frequent code paths, such as resolving a user’s access.

Invalidating based on the future event

Out of the box, Drupal can invalidate objects based on their identity (e.g., node:1) or entire classes of objects based on their type (e.g., node_blog_list). This isn’t without its own performance considerations, but does cover many default use cases and is easy to understand. Don’t confuse tags with contexts (this was a favorite interview question when I was a CTO), the latter of which determines under what conditions the cached data may vary.

But what of invalidation that must occur based on related entities, for instance a user’s profiles? And more specifically, what if the user has yet to create any profiles? We can’t include those related entities in the referencing entity’s cache tags, because we don’t know their ID(s). Furthermore, we don’t want to invalidate based on the list cache tag (though this would “work”), because it would trigger on every profile, not just those of the user in question.

This is where Drupal’s Cache API begins to shine. Cache tags are just strings of text, and so we can create custom tags and invalidate against them at will.

Using this important knowledge, we can craft cache tags that invalidate classes of objects based on their characteristics, and because tags are simply flags sent to the invalidation system, new entities can invalidate dependent objects simply by virtue of their creation. This is very much like the [entity_type_id]_list “list cache tags” supported out of the box, but refined to a narrower set.

A simple utility to create cache tags to invalidate per owner.

In my earlier example, we pull through certain data from the user’s dating profile when assessing access by user, and conversely user profiles contain computed sanitized data from a user’s account (e.g., their age, so as not to directly reveal their birthdate.) We need to invalidate each object when the other is saved, created or deleted.

A simple utility class allows for the programmatic creation of this custom cache tag, as well as giving our IDE a method call to index, so we can quickly search for uses of this pattern in our codebase.

<?php

declare(strict_types=1);

namespace Drupal\kinksters_system;

use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\user\EntityOwnerInterface;

/**
 * Helper to generate entity owner cache tags to invalidate on save.
 */
final class EntityOwnerCacheTags {

  /**
   * Generate cache tags to invalidate on save for an entity.
   *
   * @param \Drupal\Core\Entity\ContentEntityInterface $entity
   *   Entity.
   *
   * @return string[]
   *   Cache tags to invalidate.
   */
  public static function getCacheTagsForEntity(ContentEntityInterface $entity): array {
    assert($entity instanceof EntityOwnerInterface);
    return self::getCacheTags(
      $entity->getEntityTypeId(),
      $entity->getOwnerId(),
      $entity->get($entity->getEntityType()->getKey('bundle'))->getString()
    );
  }

  /**
   * Get cache tags to invalidate given an entity, owner and optional bundle.
   *
   * @param string $entityTypeId
   *   Entity type ID.
   * @param int $entityOwnerId
   *   Entity Owner ID.
   * @param string|NULL $bundle
   *   Bundle, if any.
   *
   * @return string[]
   *   Cache tags to invalidate.
   */
  public static function getCacheTags(string $entityTypeId, int $entityOwnerId, ?string $bundle = NULL): array {
    return $bundle
      ? [sprintf('%s_%s:owner:%s', $entityTypeId, $bundle, $entityOwnerId)]
      : [sprintf('%s:owner:%s', $entityTypeId, $entityOwnerId)];
  }

}

The first method, ::getCacheTagsForEntity(), yields a result we may merge into an object’s implementation of EntityInterface::getCacheTagsToInvalidate().

The second method, ::getCacheTags(), is for use in any context where another entity type’s cache tags must be invalidated. This could be in a referenced entity’s ::getCacheTagsForEntity() method, or in custom code that knows it must invalidate data based on business rules.

It’s a good idea to namespace your cache tags in such a way that they won’t likely be duplicated in core or contrib patterns, but this is easy enough to avoid.

Happy caching, and may your hit rates always increase.

Updated: