Drupal 8 Add Cache Metadata to Render Arrays

Table of contents

Some operations are time consuming, really heavy memory and/or CPU intensive. By performing an operation one time, and then caching the output, the next requests could be executed faster. Drupal provides an easy cache API in order to store, retrieve and invalidate cache data. I did this tutorial because I couldn’t find a step by step tutorial in order to add cache metadata to render arrays easily!

In this tutorial we’ll:

  • Get an overview of the render array caches and how to use them properly.
  • We are going to get our hands dirty on code.

Prerequisites

  • Familiarity with custom module development.
  • How to create a custom controller to process incoming requests.
  • Some knowledge of render arrays.

Overview of Render array

Drupal uses render arrays to generate HTML that is presented to the end user. While render arrays are a complex topic, let’s cover the basics. A render array is an associative array that represents a one or more HTML elements, properties and values. If you’re interested in more about render arrays, see Render arrays from official Drupal docs.

Cache metadata to render array

When we have a render array, instructing Drupal to cache the results is easy, we only need to use the #cache property. But what kind of caching? Drupal 8 provides several kinds out of the box:

  • max-age stores cache data by defining its time in integer format and seconds
  • tags is an array of one or more cache tags identifying the data this element depends on.
  • contexts specifies one or more cache context IDs. These are converted to a final value depending on the request. For instance, ‘user’ is mapped to the current user’s ID.

Creating the module and controller

$ drupal generate:module --machine-name=d8_cache

A module alone isn’t enough. We also need a controller to respond to incoming requests. We can use Drupal Console to generate the controller too:

$ drupal generate:controller --module=d8_cache --class=DefaultController

When creating the controller, you’ll enter into a loop where you can enter three pieces of information necessary for the controller to define a route: The title, method name, and the path. Let’s make one route for each of the cache types:

**Title** **Method Name** **Path**
cacheMaxAge cacheMaxAge /d8_cache/max-age
cacheContextsByUrl cacheContextsByUrl /d8_cache/contexts
cacheTags cacheTags /d8_cache/tags

Now we should have an *.info.yml, *.routing.yml and our controller class.inally, let’s enable our custom module:

 $ drupal module:install d8_cache

Cache “max-age”

With the module and routes created, we can now start playing with Drupal caching.In DefaultController.php, locate the cacheMaxAge() method and add the following:

public function cacheMaxAge() {
 return [
   '#markup' => t('Temporary by 10 seconds @time', ['@time' => time()]),
   '#cache' => [
     'max-age' => 10,
   ]
 ];
}

If we open a web browser and navigate to http://your_drupal_site.test/d8_cache/max-age, we see a “Temporary by 10 seconds timestamp” where timestamp is the current time as a UNIX timestamp.
“What good is that!?” you might ask. Well, if you refresh the page you’ll notice something interesting. The first time the page will say something like “Temporary by 10 seconds 1520173774”. If we hit refresh immediately, we’ll see:

“Temporary by 10 seconds 1520173780” (the first second)

“Temporary by 10 seconds 1520173780” (in the next second)

“Temporary by 10 seconds 1520173780” (and so on)

The timestamp doesn’t change! If we wait for the whole 10 seconds we specified in max-age, the cache invalidates/expires and and is replaced with a new timestamp: “Temporary by 10 seconds 1520173790”

Great, this worked like a charm!

What if we want to make it so the page never expires? Drupal provides a special constant for this, DrupalcorecacheCache::PERMANENT exactly for this case. We’d only need to change the value of max-age:

public function cacheMaxAge() {
  return [
    '#markup' => t('WeKnow is the coolest @time', ['@time' => time()]),
    '#cache' => [
      'max-age' => DrupalCoreCacheCache::PERMANENT,
    ]
  ];
}

And the message for instance “weKnow is the coolest 1520173780” will never change! Well, not “never”. We can force the page to update by clearing the Drupal cache. This can be done under Admin > Config > Development > Performance, or using Drupal Console:

$ drupal cr all

So that was max-age, one of the simplest caching strategies. What if we need something more…nuanced?

Cache “contexts”

Caching by contexts let us specify a condition by which something remains cached. A simple example is the URL Query, or any part after the ? in a URL. We already defined the route earlier, so we open DefaultController.php and edit the cacheContextByUrl() method:

public function cacheContextsByUrl() {
  return [
    '#markup' => t('WeKnow is the coolest @time', ['@time' => time()]),
    '#cache' => [
      'contexts' => ['url.query_args'],
    ]
  ];
}

The above piece of code will display a message such as “weKnow is the coolest 1520173780”, and invalidate cache when a new query parameter from url is set or gets updated.

If we visit for instance http://your_drupal_site.test/d8_cache/contexts the first time, we’ll see something like: “weKnow is the coolest 1520173780”. If we hit again the same message is displayed. But, if we do add a query parameter like http://your_drupal_site.test/d8_cache/contexts?query_a=value, then the cache is invalidated and the page updates with a new timestamp: “weKnow is the coolest 1520173909”.

Sometimes, we only want to invalidate the cache based on a specific argument in the URL query. We can do that too:

public function cacheContextsByUrlParam() {
  return [
    '#markup' => t('WeKnow is the coolest @time', ['@time' => time()]),
    '#cache' => [
      'contexts' => ['url.query_args:your_query_param'],
    ]
  ];
}

Now if we visit the following URL:

http://your_drupal_site.test/d8_cache/contexts-param?your_query_param=value

Only then does the message change:“weKnow is the coolest 1520173909” If we visit the same URL with the same query parameter set (your_query_param), the cache is invalidated and we get a new timestamp once again:

“weKnow is the coolest 1520173910”
And so on…

The url.query_args:your_query_param value we passed to contexts in our render array instructs Drupal to only invalidate the cache if a certain URL query parameter is set.

If we visit:

http://your_drupal_site.test/d8_cache/contexts-param?this_is_another_query_param=value

The message is “weKnow is the coolest 1520173910” (first second)
“weKnow is the coolest 1520173910” (next second)
“weKnow is the coolest 1520173910” (after few minutes)

And so on!

Notice the message doesn’t change. This is because we set to invalidate cache on the query param “your_query_param” and above is another query param. Since your_query_param is not in our URL, Drupal will never invalidate the cache.

Caching by the URL query isn’t the only context available in Drupal. There are several others:

  • theme (vary by negotiated theme)
  • user.roles (vary by the combination of roles)
  • user.roles:anonymous (vary by whether the current user has the ‘anonymous’ role or not, i.e. “is anonymous user”)
  • languages (vary by all language types: interface, content …)
  • languages:language_interface (vary by interface language — LanguageInterface::TYPE_INTERFACE)
  • languages:language_content (vary by content language — LanguageInterface::TYPE_CONTENT)
  • url (vary by the entire URL)
  • url.query_args (vary by the entire given query string)
  • url.query_args:foo (vary by the ?foo query argument

Refer to drupal 8 contexts official documentation for more details about cache “contexts”.

Cache “tags”

The contexts cache type is really versatile, but sometimes we need more complete control over what is and isn’t cached. For that, there’s tags. Open the controller and modify the cacheTags() method to be the following:

public function cacheTags() {
  $userName = Drupal::currentUser()->getAccountName();
  $cacheTags =   User::load(Drupal::currentUser()->id())->getCacheTags();
  return [
    '#markup' => t('WeKnow is the coolest! Do you agree @userName ?', ['@userName' => $userName]),
    '#cache' => [
     // We need to use entity->getCacheTags() instead of hardcoding "user:2"(where 2 is uid) or trying to memorize each pattern.
      'tags' => $cacheTags,
    ]
  ];
}

Ok, now let’s login with our username — this post uses “Eduardo” — and visit:

http://your_drupal_site.test/d8_cache/tags

Above code prints “weKnow is the coolest! Do you agree Eduardo?” If we hit the page again it will say “weKnow is the coolest! Do you agree Eduardo?” and subsequent requests will say the same.

If we edit our own username to “EduardoTelaya” and hit save our tag cached page changes:

“weKnow is the coolest! Do you agree EduardoTelaya?”

Why is that?

If you look closely at the method, you’ll notice we get a list of cache tags for the current user. If we use a debugger to see the value of $cacheTags, it will say “user:userID” where userID is the user’s unique ID number. When we updated our user account, Drupal invalidated any cached content associated with that tag. Cache tags let us build a dependency into our cache on another entity or entities in the site. We can even define our own tags to have full control!

Tips and tricks

In the above examples we only had one #cache in each render array. Drupal allows us to specify the caching at different levels in the tree depending on need. Let’s suppose we have the following, a tree of render array:

public function cacheTree() {

   return [
     'permanent' => [
       '#markup' => 'PERMANENT: weKnow is the coolest ' . time() . '<br>',
        '#cache' => [
          'max-age' => Cache::PERMANENT,
       ],
     ],
     'message' => [
       '#markup' => 'Just a message! <br>',
       '#cache' => [
       ]
     ],
     'parent' => [
         'child_a' => [
           '#markup' => '--->Temporary by 20 seconds ' . time() . '<br>',

         '#cache' => [
           'max-age' => 20,
       ],
     ],
      'child_b' => [
        '#markup' => '--->Temporary by 10 seconds ' . time() . '<br>',
        '#cache' => [
          'max-age' => 10,
        ],
      ],
    ],
    'contexts_url' => [
      '#markup' => 'Contexts url - ' . time(),
      '#cache' => [
        'contexts' => ['url.query_args'],
      ]
    ]
  ];
}

If we visit the first time http://your_drupal_site.test/d8_cache/tree:

We get this:

PERMANENT: weKnow is the coolest 1520261602
Just a message!
—>Temporary by 20 seconds 1520261602
—>Temporary by 10 seconds 1520261602
Contexts url – 1520261602

(Please refer to timestamp above for example purposes)

In the next second, if we visit the same page again, we get the same message. But once it reaches 10 seconds, the cache is invalidated thanks to the render array element “child_b” (which was set to expire/invalidate to 10 seconds) and we are going to have a different message:

PERMANENT: weKnow is the coolest 1520261612
Just a message!
—>Temporary by 20 seconds 1520261612
—>Temporary by 10 seconds 1520261612
Contexts url – 1520261612

Notice how not only “child_b” was updated but also the rest of render array elements. The same will happen if you wait 20 seconds or visit /d8_cache/tree?query=value, which invalidates cache according to url query contexts.

This is called “bubbling up cache”. This can affect the response cache you can see as a whole! In order to avoid that you should use “keys” attribute in order to cache individual elements. By adding “keys” you protect from cache invalidation from siblings array elements and children array elements. Let’s add a new method and path to our code in order to add keys:

public function cacheTreeKeys() {

 return [
   'permanent' => [
     '#markup' => 'PERMANENT: weKnow is the coolest ' . time() . '<br>',
     '#cache' => [
       'max-age' => Cache::PERMANENT,
       'keys' => ['d8_cache_permament']
     ],
   ],
   'message' => [
     '#markup' => 'Just a message! <br>',
     '#cache' => [
       'keys' => ['d8_cache_time']
     ]
   ],
   'parent' => [
     'child_a' => [
       '#markup' => '--->Temporary by 20 seconds ' . time() . '<br>',
       '#cache' => [
         'max-age' => 20,
         'keys' => ['d8_cache_child_a']
       ],
     ],
     'child_b' => [
       '#markup' => '--->Temporary by 10 seconds ' . time() . '<br>',
       '#cache' => [
         'max-age' => 10,
         'keys' => ['d8_cache_child_b']
       ],
     ],
   ],
   'contexts_url' => [
     '#markup' => 'Contexts url - ' . time(),
     '#cache' => [
       'contexts' => ['url.query_args'],
      'keys' => ['d8_cache_contexts_url']
     ]
   ]
 ];
}

If we visit now /d8_cache/tree-keys

We will get:

PERMANENT: weKnow is the coolest 1520261612
Just a message!
—>Temporary by 20 seconds 1520261612
—>Temporary by 10 seconds 1520261612
Contexts url – 1520261612

And if we wait for 10 seconds we are going to see:

PERMANENT: weKnow is the coolest 1520261612
Just a message!
—>Temporary by 20 seconds 1520261612
—>Temporary by 10 seconds 1520261622
Contexts url – 1520261612

Notice how just “—>Temporary by 10 seconds 1520261622” gets updated but the rest of the output doesn’t get updated (this is thanks to keys attribute that prevent cache invalidation to the rest of array elements).

Download

You can download full source code for this post on Github.

Recap

In this post, we saw an overview of render arrays, how to use three different cache types. We used max-age for simple, time-based caching. Cache contexts provides a caching strategy based on a variety of dynamic conditions. The tags cache type lets us invalidate caches based on the activity on other entities or full control via custom tag names. Finally, we used “cache keys” to protect against other cache invalidation in a render array tree.

This is it! I hope you enjoyed this tutorial! Stay tuned for more!

This post was contributed by Eduardo Telaya, a former member of the weKnow team. You can find him on Twitter at @Edutrul, or speaking at Drupal events in Latin America such as Drupalcamp Costa Rica.