Transform Learning with a Powerful Enterprise LMS

Learn more
Moodle

Moodle Cache API (MUC) Example: Defining Caches in a Plugin

Define a Moodle Universal Cache in a plugin: write db/caches.php, add the language string, load misses through a data source, invalidate on enrolment events and choose the right options.

Learn more about this topic with AI

Gemini opens with the prompt copied - paste it into the chat to start.

Illustration of a Moodle plugin cache definition flowing from db/caches.php through a data source into a cache store

To define a cache in a Moodle plugin, add a db/caches.php file that returns a $definitions array, give each definition a mode (Application, Session or Request), add a cachedef_<name> language string, bump the plugin version and run the upgrade. Then call \core_cache\cache::make('local_myplugin', 'name') and use get(), set() and delete(). This Moodle cache API MUC example walks through a complete plugin cache: a definition, a data source that fills the cache on a miss, invalidation when the underlying data changes, and the options worth knowing before you ship.

MUC, the Moodle Universal Cache, separates what your code caches (the definition) from where the data lives (the store an administrator maps it to). Your plugin only describes its needs; the site decides whether that lands in the file store, Redis or APCu. That separation is why a well-written definition matters more than which store you test on.

Checked against the Moodle 5.3.0 source (public/cache/classes, public/lib/db/caches.php, mod_quiz's cache data source, UPGRADING.md) and the Cache API page of the Moodle developer documentation (October 2026). The code below has not been run on a live site; test on staging first.

Definitions, stores and modes in one minute

A definition is a named cache area owned by a component. Moodle reads every db/caches.php during install and upgrade (cache_helper::update_definitions() is called from the upgrade code), stores the result in the cache configuration and lists it under Site administration > Plugins > Caching > Configuration, in the "Known cache definitions" table. Administrators map definitions to store instances there; anything unmapped goes to the default store for its mode.

ModeConstantScopeTypical use
Application\core_cache\store::MODE_APPLICATIONShared by all users and requestsComputed data that is the same for everyone: config lookups, aggregates, parsed structures
Session\core_cache\store::MODE_SESSIONOne user's sessionPer-user data such as what a user can see in a list
Request\core_cache\store::MODE_REQUESTThe current request onlyRepeated lookups within one page load

Moodle 5.3 ships five store plugins: APCu, File, Redis, Session and Static. Since Moodle 4.5 the cache classes live in the core_cache namespace (MDL-82158): \cache is \core_cache\cache, \cache_store is \core_cache\store, \cache_data_source is \core_cache\data_source_interface and \cache_helper is \core_cache\helper. The old names are still aliased in 5.3, so code that targets 4.1 to 4.4 as well can keep them; new code for 4.5 and later should use the namespaced names.

Step 1: Write db/caches.php

The example plugin is local_teamstats, which shows the number of enrolled users per course on a dashboard and does not want to run a COUNT query for every course on every page view. Our local plugin tutorial covers the plugin skeleton itself.

<?php
// local/teamstats/db/caches.php
defined('MOODLE_INTERNAL') || die();

$definitions = [
    'enrolcounts' => [
        'mode' => \core_cache\store::MODE_APPLICATION,
        'simplekeys' => true,
        'simpledata' => true,
        'staticacceleration' => true,
        'staticaccelerationsize' => 100,
        'datasource' => \local_teamstats\cache\enrolcounts::class,
    ],
];

The array key (enrolcounts) is the area name; the component comes from the file location. Only mode is required. What the other options do here:

  • simplekeys: the keys are course ids, which only contain a-zA-Z0-9_, so the API can skip hashing them.
  • simpledata: the values are integers, so they are stored as they are rather than serialised.
  • staticacceleration and staticaccelerationsize: keep up to 100 values in a PHP array for the rest of the request, so a dashboard that asks for the same course twice hits memory, not the store.
  • datasource: the class that loads a value when the key is missing. With a data source, get() effectively never misses.

This mirrors core's own pattern. In 5.3, mod/quiz/db/caches.php defines quiz_overrides as an Application cache with simplekeys and a datasource class, \mod_quiz\cache\quiz_overrides_cache.

Step 2: Add the language string and bump the version

Every definition needs a string named cachedef_ plus the area name. It is what administrators see in the definitions table.

// local/teamstats/lang/en/local_teamstats.php
$string['cachedef_enrolcounts'] = 'Enrolled user counts per course';

Then increase $plugin->version in version.php and run the upgrade (Site administration > Notifications, or php admin/cli/upgrade.php). The upgrade re-reads the definitions. During development you can also use "Rescan definitions" on the cache Configuration page instead of bumping the version each time you change caches.php. If you need a refresher on version numbers, see the version.php reference.

Step 3: Write the data source class

A data source implements \core_cache\data_source_interface, which has three methods: a static get_instance_for_cache(definition $definition), load_for_cache($key) and load_many_for_cache(array $keys). Put the class where the definition says, so it autoloads: local/teamstats/classes/cache/enrolcounts.php.

<?php
namespace local_teamstats\cache;

use core_cache\data_source_interface;
use core_cache\definition;

/**
 * Loads enrolled user counts for the enrolcounts cache.
 *
 * Not run on a live site; test on staging first.
 */
class enrolcounts implements data_source_interface {
    /** @var enrolcounts|null */
    private static $instance = null;

    public static function get_instance_for_cache(definition $definition): enrolcounts {
        return self::$instance ??= new enrolcounts();
    }

    public function load_for_cache($key) {
        global $DB;
        $sql = "SELECT COUNT(DISTINCT ue.userid)
                  FROM {user_enrolments} ue
                  JOIN {enrol} e ON e.id = ue.enrolid
                 WHERE e.courseid = :courseid";
        return (int) $DB->count_records_sql($sql, ['courseid' => (int) $key]);
    }

    public function load_many_for_cache(array $keys) {
        $results = [];
        foreach ($keys as $key) {
            $results[$key] = $this->load_for_cache($key);
        }
        return $results;
    }
}

Two details matter. First, load_for_cache() must return false only when there is genuinely nothing to load, because false is how the cache signals a miss; a count of zero is a real value and is cached as such. Second, if you later add invalidationevents to this definition, the cache will ask for an internal key called lastinvalidation on start-up. Core's quiz data source returns false for that key before doing any work; copy that guard if your keys are parsed, otherwise the internal key reaches your SQL.

For many keys, load_many_for_cache() is the place to batch: one GROUP BY e.courseid query with $DB->get_in_or_equal() beats a loop when a page asks for 50 courses through get_many(). The loop above keeps the example short.

Step 4: Read from the cache

$cache = \core_cache\cache::make('local_teamstats', 'enrolcounts');

// One course: loaded from the store, or from the data source on a miss.
$count = $cache->get($course->id);

// Several courses in one call.
$counts = $cache->get_many(array_keys($courses));

cache::make() is a factory: it returns an application, session or request cache object depending on the definition's mode, all implementing \core_cache\loader_interface. Without a data source the usual pattern is get, check for false, compute, set(). With one, the cache does that for you. get() also accepts MUST_EXIST as a second argument when a miss should throw rather than return false.

Keys must be integers or strings, and values anything serialisable. Objects that need special handling can implement \core_cache\cacheable_object_interface (prepare_to_cache() and wake_from_cache()) so they are stored as plain data and rebuilt on read.

Step 5: Invalidate when the data changes

A cache with no invalidation is a bug waiting for a support ticket. MUC gives you three tools, and choosing between them is the main design decision in any cache definition.

ToolCallUse when
Delete specific keys\core_cache\helper::invalidate_by_definition($component, $area, $identifiers, $keys) or $cache->delete($key)You know exactly which keys changed
Named invalidation events'invalidationevents' => [...] in the definition, then \core_cache\helper::invalidate_by_event($event, $keys) or purge_by_event($event)Several caches depend on the same change and the caller should not need to know which
Purge the whole definition\core_cache\helper::purge_by_definition($component, $area)The change affects most keys, or the data is cheap to rebuild

For enrolment counts, the course id of the changed enrolment is the key, so targeted deletion is right. Observe the core enrolment events in db/events.php:

// local/teamstats/db/events.php
$observers = [
    ['eventname' => '\core\event\user_enrolment_created',
     'callback' => '\local_teamstats\observer::enrolment_changed'],
    ['eventname' => '\core\event\user_enrolment_deleted',
     'callback' => '\local_teamstats\observer::enrolment_changed'],
];

// local/teamstats/classes/observer.php
namespace local_teamstats;

class observer {
    public static function enrolment_changed(\core\event\base $event): void {
        \core_cache\helper::invalidate_by_definition(
            'local_teamstats', 'enrolcounts', [], [$event->courseid]);
    }
}

Our event observer example covers the observer file in more depth. Note that the "events" in invalidationevents are not Moodle Events API events: they are plain strings such as core's changesincoursecat, which lib/db/caches.php attaches to coursecattree and coursecat. Also note the source comment in core_cache\helper: event-based purge and invalidation are only supported on caches without identifiers.

Options you will reach for later

OptionWhat it doesAdvice
requireidentifiersIdentifiers that must be passed as the third argument of cache::make(), and that become part of the key spaceUseful for per-context variants; not compatible with event invalidation
requiredataguaranteeOnly stores that keep data until it is removed can be mappedFor data you cannot recompute cheaply
requirelockingbeforewriteCalling code must hold a lock while generating the valuePrevents stampedes; core uses it for course modinfo
ttlTime to live in secondsThe docs discourage it; not every store supports it natively. Prefer invalidation
maxsizeSuggested maximum number of entriesStores may ignore it
canuselocalstoreThe cache is safe on a per-node store in a clusterOnly if keys change whenever content changes
mappingsonly, overrideclassSkip default stores; replace the loader classRarely justified

For data with a version number in the database (a revision field, a timestamp), use the versioned pair set_versioned($key, $version, $data) and get_versioned($key, $requiredversion). get_versioned() returns only data at or above the required version, which makes the cache safe to place on a local store in front of a shared one. The two must be used together; a value written with set_versioned() cannot be read with get(). A data source for a versioned cache implements \core_cache\versionable_data_source_interface.

Ad hoc caches, created with cache::make_from_params(\core_cache\store::MODE_APPLICATION, 'local_teamstats', 'scratch'), need no definition, but they get default settings only and administrators cannot map them. The Cache API docs reserve them for insignificant caches used in one place.

Check that it works

  1. After the upgrade, open Site administration > Plugins > Caching > Configuration and find enrolcounts under "Known cache definitions". If it is missing, the language string or the file location is wrong, or the version was not bumped.
  2. Load the dashboard twice. On a development site, turn on "Performance info" (Site administration > Development > Debugging, setting perfdebug); the footer then lists "Caches used (hits/misses/sets)" per definition: the first load should show a miss and a set, the second a hit.
  3. Enrol a user and reload. The count should change, which proves the observer invalidated the key.
  4. Check Site administration > Plugins > Caching > Cache usage for the size of the definition per store once real traffic hits it.
  5. Run php admin/cli/purge_caches.php and confirm the page still renders correctly from a cold cache.

Where the definition ends up living is an administrator decision. If the site maps Application caches to Redis, see configuring a Moodle Redis cache for the store side. The Moodle guide puts caching in the context of the rest of a production stack.

Common mistakes

  • Caching per-user data in an Application cache without the user id in the key. Every user then sees the first user's result.
  • Setting simplekeys with keys that contain hyphens, dots or slashes. Keys must match a-zA-Z0-9_; otherwise leave hashing on.
  • Using ttl as the only invalidation. Users see stale data until it expires, and stores without native TTL pay a performance cost.
  • Static acceleration on large values without a size limit. Each value stays in PHP memory for the request; set staticaccelerationsize.
  • Forgetting CLI and cron. Code that changes data from a scheduled task must invalidate the same way as the web path.

How we can help

We build and review Moodle plugins, including cache design: which mode, which keys, how invalidation is wired, and how the definitions behave on the stores your site actually uses. See our Moodle development services.

Frequently asked questions

Where do I define a cache in a Moodle plugin?

In db/caches.php inside the plugin, as a $definitions array keyed by area name. Core definitions live in lib/db/caches.php. Add a cachedef_<area> string to the plugin's language file and bump the version.

What is the difference between cache::make() and cache::make_from_params()?

cache::make() uses a definition from db/caches.php, so administrators can see it and map it to a store. cache::make_from_params() creates an ad hoc cache with default settings that cannot be mapped; the docs recommend it only for insignificant caches used in one place.

Should I use ttl to expire Moodle cache data?

Usually not. The Cache API documentation strongly recommends event-driven invalidation instead, because not every store supports TTL natively and emulating it costs performance. Delete or invalidate keys when the source data changes.

Do I still use cache_store::MODE_APPLICATION in Moodle 5.3?

It still works, because Moodle 5.3 aliases the old class names. Since Moodle 4.5 the namespaced form is \core_cache\store::MODE_APPLICATION, which core files such as mod/quiz/db/caches.php now use. Keep the old names only if the plugin must also support releases before 4.5.

Free 30-minute consultation

Not sure where to start? Talk to an LMS engineer.

Tell us what platform you run and what you need. You’ll get honest, practical advice - even if the answer is that you don’t need us.

Sources & references

  1. Cache API (MUC) - Moodle Developer Resources
  2. core_cache classes in Moodle 5.3.0 - Moodle HQ (GitHub)
  3. Core cache definitions (lib/db/caches.php) - Moodle HQ (GitHub)
  4. Moodle UPGRADING.md (core_cache class renames, MDL-82158) - Moodle HQ (GitHub)
Add as a preferred source on Google

Add All-in-One LMS as a preferred source to see more of our articles in Google’s Top Stories.

Learn more about this topic with AI

Gemini opens with the prompt copied - paste it into the chat to start.

Keep reading

Before you go - get a free LMS audit

Tell us which LMS you use and what is not working. An engineer replies within one business day.

Choose which optional cookies we may use. You can change this at any time from "Cookie preferences" in the footer. Cookie policy · Privacy policy