Cache Basics: Reading and Writing with Drupal's Cache APIfor Drupal 11 , and 10

Last updated :  

Imagine your module has to do something slow every time a page loads — scan thousands of files, run a heavy database query, call a remote API. Do that on every single request and your site crawls. Do it once, remember the answer, and reuse it — and your site flies. That second approach has a name in Drupal: the Cache API, and this lesson is where you learn to use it properly.

We're going to read the real cache_example module from Drupal's official Examples project, which was built for exactly this purpose: a working, interactive demo of get/set/delete/expire, all in one form you can click through yourself.

What you'll learn in this lesson

  • The "cache-aside" pattern — the standard shape of almost all caching code in Drupal
  • How to read from and write to a cache bin with get() and set()
  • The difference between a permanent cache item and one that expires on its own
  • How to remove cached data explicitly when you know it's gone stale
Where this fits: caching is a performance topic, not a "getting started" one — this lesson assumes you're already comfortable with basic module structure, dependency injection, and the Form API from earlier in this course. If any of that feels unfamiliar, the earlier lessons in this course cover it.

The problem caching solves

The demo form you're about to read does something deliberately slow: it recursively scans Drupal core's entire folder counting .php files. That's thousands of files on disk — the kind of operation that takes real, measurable time. Do it on every page load and you've made your site slow for no good reason, because the answer barely ever changes. Caching means: do the slow work once, remember the result, and hand back the remembered answer on every later request — until you have a good reason to recompute it.

The source file

Path (relative to the Examples module's root): modules/cache_example/src/Form/CacheExampleForm.php

<?php

namespace Drupal\cache_example\Form;

use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Cache\CacheBackendInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * Form with examples on how to use cache.
 */
class CacheExampleForm extends FormBase {

  /**
   * The current user.
   *
   * @var \Drupal\Core\Session\AccountProxyInterface
   */
  protected $currentUser;

  /**
   * The cache.default cache backend.
   *
   * @var \Drupal\Core\Cache\CacheBackendInterface
   */
  protected $cacheBackend;

  /**
   * The date formatter service.
   *
   * @var \Drupal\Core\Datetime\DateFormatterInterface
   */
  protected $dateFormatter;

  /**
   * The file system service.
   *
   * @var \Drupal\Core\File\FileSystemInterface
   */
  protected $fileSystem;

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container) {
    // Forms that require a Drupal service or a custom service should access
    // the service using dependency injection.
    // @link https://www.drupal.org/node/2203931.
    // Those services are passed in the $container through the static create
    // method.
    $form = new static();
    $form->setRequestStack($container->get('request_stack'))
      ->setStringTranslation($container->get('string_translation'))
      ->setMessenger($container->get('messenger'));
    $form->currentUser = $container->get('current_user');
    $form->cacheBackend = $container->get('cache.default');
    $form->dateFormatter = $container->get('date.formatter');
    $form->fileSystem = $container->get('file_system');
    return $form;
  }

  /**
   * {@inheritdoc}
   */
  public function getFormId() {
    return 'cron_cache';
  }

  /**
   * {@inheritdoc}
   */
  public function buildForm(array $form, FormStateInterface $form_state) {
    // Log execution time.
    $start_time = microtime(TRUE);

    // Try to load the files count from cache. This function will accept two
    // arguments:
    // - cache object name (cid)
    // - cache bin, the (optional) cache bin (most often a database table) where
    //   the object is to be saved.
    //
    // cache_get() returns the cached object or FALSE if object does not exist.
    if ($cache = $this->cacheBackend->get('cache_example_files_count')) {
      /*
       * Get cached data. Complex data types will be unserialized automatically.
       */
      $files_count = $cache->data;
    }
    else {
      // If there was no cached data available we have to search filesystem.
      // Recursively get all .PHP files from Drupal's core folder.
      $files_count = count($this->fileSystem->scanDirectory('core', '/.php/'));

      // Since we have recalculated, we now need to store the new data into
      // cache. Complex data types will be automatically serialized before
      // being saved into cache.
      // We use the default setting and create a cache item that does not
      // expire. See below for an example that creates an expiring cache item.
      $this->cacheBackend->set('cache_example_files_count', $files_count, CacheBackendInterface::CACHE_PERMANENT);
    }

    $end_time = microtime(TRUE);
    $duration = $end_time - $start_time;

    // Format intro message.
    $intro_message = '<p>' . $this->t("This example will search Drupal's core folder and display a count of the PHP files in it.") . ' ';
    $intro_message .= $this->t('This can take a while, since there are a lot of files to be searched.') . ' ';
    $intro_message .= $this->t('We will search filesystem just once and save output to the cache. We will use cached data for later requests.') . '</p>';
    $intro_message .= '<p>'
      . $this->t(
        '<a href=":url">Reload this page</a> to see cache in action.',
        [':url' => $this->getRequest()->getRequestUri()]
      )
      . ' ';
    $intro_message .= $this->t('You can use the button below to remove cached data.') . '</p>';

    $form['file_search'] = [
      '#type' => 'fieldset',
      '#title' => $this->t('File search caching'),
    ];
    $form['file_search']['introduction'] = [
      '#markup' => $intro_message,
    ];

    $color = empty($cache) ? 'red' : 'green';
    $retrieval = empty($cache) ? $this->t('calculated by traversing the filesystem') : $this->t('retrieved from cache');

    $form['file_search']['statistics'] = [
      '#type' => 'item',
      '#markup' => $this->t('%count files exist in this Drupal installation; @retrieval in @time ms. <br/>(Source: <span style="color:@color;">@source</span>)', [
        '%count' => $files_count,
        '@retrieval' => $retrieval,
        '@time' => number_format($duration * 1000, 2),
        '@color' => $color,
        '@source' => empty($cache) ? $this->t('actual file search') : $this->t('cached'),
      ]
      ),
    ];
    $form['file_search']['remove_file_count'] = [
      '#type' => 'submit',
      '#submit' => ['::expireFiles'],
      '#value' => $this->t('Explicitly remove cached file count'),
    ];

    $form['expiration_demo'] = [
      '#type' => 'fieldset',
      '#title' => $this->t('Cache expiration settings'),
    ];
    $form['expiration_demo']['explanation'] = [
      '#markup' => $this->t('A cache item can be set as CACHE_PERMANENT, meaning that it will only be removed when explicitly cleared, or it can have an expiration time (a Unix timestamp).'),
    ];

    $item = $this->cacheBackend->get('cache_example_expiring_item', TRUE);
    if ($item == FALSE) {
      $item_status = $this->t('Cache item does not exist');
    }
    else {
      $item_status = $item->valid ? $this->t('Cache item exists and is set to expire at %time', ['%time' => $item->data]) :
      $this->t('Cache_item is invalid');
    }

    $form['expiration_demo']['current_status'] = [
      '#type' => 'item',
      '#title' => $this->t('Current status of cache item "cache_example_expiring_item"'),
      '#markup' => $item_status,
    ];
    $form['expiration_demo']['expiration'] = [
      '#type' => 'select',
      '#title' => $this->t('Time before cache expiration'),
      '#options' => [
        'never_remove' => $this->t('CACHE_PERMANENT'),
        -10 => $this->t('Immediate expiration'),
        10 => $this->t('10 seconds from form submission'),
        60 => $this->t('1 minute from form submission'),
        300 => $this->t('5 minutes from form submission'),
      ],
      '#default_value' => -10,
      '#description' => $this->t('Any cache item can be set to only expire when explicitly cleared, or to expire at a given time.'),
    ];
    $form['expiration_demo']['create_cache_item'] = [
      '#type' => 'submit',
      '#value' => $this->t('Create a cache item with this expiration'),
      '#submit' => ['::createExpiringItem'],
    ];

    $form['cache_clearing'] = [
      '#type' => 'fieldset',
      '#title' => $this->t('Expire and remove options'),
      '#description' => $this->t("We have APIs to expire cached items and also to just remove them. Unfortunately, they're all the same API, cache_clear_all"),
    ];
    $form['cache_clearing']['cache_clear_type'] = [
      '#type' => 'radios',
      '#title' => $this->t('Type of cache clearing to do'),
      '#options' => [
        'expire' => $this->t('Remove items from the "cache" bin that have expired'),
        'remove_all' => $this->t('Remove all items from the "cache" bin regardless of expiration'),
        'remove_tag' => $this->t('Remove all items in the "cache" bin with the tag "cache_example" set to 1'),
      ],
      '#default_value' => 'expire',
    ];
    $form['cache_clearing']['clear_expired'] = [
      '#type' => 'submit',
      '#value' => $this->t('Clear or expire cache'),
      '#submit' => ['::cacheClearing'],
      '#access' => $this->currentUser->hasPermission('administer site configuration'),
    ];

    return $form;
  }

  /**
   * Submit handler that explicitly clears cache_example_files_count from cache.
   */
  public function expireFiles($form, &$form_state) {
    // Clear cached data. This function will delete cached object from cache
    // bin.
    //
    // The first argument is cache id to be deleted. Since we've provided it
    // explicitly, it will be removed whether or not it has an associated
    // expiration time. The second argument (required here) is the cache bin.
    // Using cache_clear_all() explicitly in this way
    // forces removal of the cached item.
    $this->cacheBackend->delete('cache_example_files_count');

    // Display message to the user.
    $this->messenger()->addMessage($this->t('Cached data key "cache_example_files_count" was cleared.'), 'status');
  }

  /**
   * Submit handler to create a new cache item with specified expiration.
   */
  public function createExpiringItem($form, &$form_state) {

    $tags = [
      'cache_example:1',
    ];

    $interval = $form_state->getValue('expiration');
    if ($interval == 'never_remove') {
      $expiration = CacheBackendInterface::CACHE_PERMANENT;
      $expiration_friendly = $this->t('Never expires');
    }
    else {
      $expiration = time() + $interval;
      $expiration_friendly = $this->dateFormatter->format($expiration);
    }
    // Set the expiration to the actual Unix timestamp of the end of the
    // required interval. Also add a tag to it to be able to clear caches more
    // precise.
    $this->cacheBackend->set('cache_example_expiring_item', $expiration_friendly, $expiration, $tags);
    $this->messenger()->addMessage($this->t('cache_example_expiring_item was set to expire at %time', ['%time' => $expiration_friendly]));
  }

  /**
   * Submit handler to demonstrate the various uses of cache_clear_all().
   */
  public function cacheClearing($form, &$form_state) {
    switch ($form_state->getValue('cache_clear_type')) {
      case 'expire':
        // Here we'll remove all cache keys in the 'cache' bin that have
        // expired.
        $this->cacheBackend->garbageCollection();
        $this->messenger()->addMessage($this->t('\Drupal::cache()->garbageCollection() was called, removing any expired cache items.'));
        break;

      case 'remove_all':
        // This removes all keys in a bin using a super-wildcard. This
        // has nothing to do with expiration. It's just brute-force removal.
        $this->cacheBackend->deleteAll();
        $this->messenger()->addMessage($this->t('ALL entries in the "cache" bin were removed with \Drupal::cache()->deleteAll().'));
        break;

      case 'remove_tag':
        // This removes cache entries with the tag "cache_example" set to 1 in
        // the "cache".
        $tags = [
          'cache_example:1',
        ];
        Cache::invalidateTags($tags);
        $this->messenger()->addMessage($this->t('Cache entries with the tag "cache_example" set to 1 in the "cache" bin were invalidated with \Drupal\Core\Cache\Cache::invalidateTags($tags).'));
        break;
    }
  }

  /**
   * {@inheritdoc}
   */
  public function submitForm(array &$form, FormStateInterface $form_state) {

  }

}

How it works

Dependency injection via create()

This form injects the cache.default service — the default cache backend — the same way you've seen other services injected in earlier lessons. cache.default is just one of several named cache "bins" Drupal ships with (others include cache.render and cache.data); all of them implement the same CacheBackendInterface, so everything you learn here transfers directly to any bin.

The cache-aside pattern

if ($cache = $this->cacheBackend->get('cache_example_files_count')) {
    $files_count = $cache->data;
}
else {
    $files_count = count($this->fileSystem->scanDirectory('core', '/.php/'));
    $this->cacheBackend->set('cache_example_files_count', $files_count, CacheBackendInterface::CACHE_PERMANENT);
}

This four-line shape is the single most important pattern in this entire lesson — you will write it, or something close to it, constantly in real Drupal modules. In plain English: "ask the cache for the answer; if it has one, use it; if not, do the expensive work, then hand the result to the cache before using it." Notice that get() returns an object with a ->data property (not the raw value directly) when it finds something, and returns plain FALSE when it doesn't — that's why the code can use it directly as a condition.

set()'s three arguments

The full call is set($cid, $data, $expire):

  • $cid — the cache ID, a unique string key within the bin (here, 'cache_example_files_count').
  • $data — the value to store. Arrays and objects are serialized automatically; you don't need to do anything special.
  • $expire — when the item should stop being valid. CacheBackendInterface::CACHE_PERMANENT means "never, until something explicitly deletes it." Passing a Unix timestamp (like time() + 3600) means "expire automatically at this moment."

The visual feedback trick

The demo colors its output red or green depending on whether the value came from the filesystem or the cache, and reports how long retrieval took in milliseconds. This is a genuinely useful debugging habit worth stealing for your own modules during development — temporarily surfacing "was this cached?" and "how long did this take?" makes caching bugs (and caching wins) immediately visible instead of invisible.

Explicit deletion: expireFiles()

$this->cacheBackend->delete('cache_example_files_count');

This removes exactly one cache item by its ID, regardless of whether it had an expiration set. Use this when you know precisely which cached value just went stale — for example, right after the underlying data it represents has changed.

Reading a possibly-expired item: the second argument to get()

$item = $this->cacheBackend->get('cache_example_expiring_item', TRUE);

Normally, an expired item behaves exactly like a missing one — get() returns FALSE. Passing TRUE as the second argument changes that: you get the cache object back even if it has technically expired, and you can check its ->valid property yourself. This is useful for "stale while revalidate" style logic, where showing slightly-outdated data is better than making the visitor wait for a fresh recalculation.

Common beginner mistake: forgetting that CACHE_PERMANENT does not mean "cached forever no matter what." It means "never expires on its own" — but Drupal's cache is still cleared on things like a full cache rebuild (drush cr), and any code (including your own) can still call delete() or deleteAll() on it at any time. "Permanent" describes the expiration rule, not a guarantee of immortality.

See it for yourself

Visit /examples/cache-example on your own DDEV site. The first load does the real (slow-ish) file search; reload the page and watch the source flip to "cached" with a dramatically shorter time.

The Cache Basics demo form showing the file count calculated live, with Source: actual file search and a time of around 220ms The same demo form after a reload, now reading Source: cached with a time of 0.64ms

Same page, same code path, one reload later: Source: actual file search at ~220ms became Source: cached at 0.64ms — a roughly 300x difference, and the entire reason is the four-line get()/set() pattern above. Nothing about the PHP logic branches differently between these two screenshots; only whether $this->cacheBackend->get('cache_example_files_count') found something already sitting in the bin.

Quick check: if you wanted a cached value to automatically refresh itself every hour without you ever calling delete(), would you use CACHE_PERMANENT or a Unix timestamp as the third argument to set()? If you said a Unix timestamp (time() + 3600), you've got it — permanent items only go away when something explicitly removes them.

Key takeaways

  • Inject cache.default (or any named cache bin) via create() rather than calling \Drupal::cache() directly — it keeps your code testable and consistent with how services work throughout this course.
  • The cache-aside pattern is: call get($cid), check the result, use ->data if found, otherwise compute the value and call set($cid, $value, $expiration).
  • Pass CacheBackendInterface::CACHE_PERMANENT when an item should persist until explicitly removed; pass a future Unix timestamp when it should expire automatically.
  • Use delete($cid) to remove one known item, deleteAll() to wipe an entire bin, and garbageCollection() to clean up only items that have already expired.
  • Passing TRUE as get()'s second argument lets you inspect an expired item instead of treating it as missing — useful for stale-while-revalidate patterns.

Coming up next

You now know how to store and retrieve a single cached value. But real applications rarely cache just one thing — they cache dozens of interrelated values, and when one piece of underlying data changes, you need a way to invalidate exactly the cached items that depended on it, without wiping everything else. That's what cache tags are for, and it's the very next lesson.