Caching

5 views Markdown

A file-backed cache with one method worth knowing. The producer contract is easy to break silently, and a few things must never go into it.

Overview

Repeated reads that are the same for everyone are cached: a catalogue, a menu tree, a price table. Entries are grouped into stores, one file per store, and reached through a single read-through call.

The installation-wide cache switch is honoured inside that call, so the producer runs on every request when caching is off. Do not check the switch yourself before calling it.

Reference

Cache
public static function remember(string $name, string $key, int $ttl, callable $producer);
public static function getInstance(): self;

// $config: a string is the store name, an array accepts 'name', 'path', 'extension'.
public function __construct($config = []);

public function store($key, $data, $expiration = 86400): bool;
public function retrieve($key, $timestamp = false);
public function isCached($key): bool;
public function erase($key): self;
public function eraseExpired(): int;
public function eraseAll(): self;
public function clear($keys = []): void;
Cache::remember() The one you want. Returns the stored value, or calls the producer, stores its result for $ttl seconds and returns that.
Cache::getInstance() The shared instance, for the calls below. Constructing one with new also replaces that shared instance, which is accepted behaviour but worth knowing.
store() Writes one entry. The value is serialized, and $expiration of 0 means it never expires. Answers false when the licence domain does not match the host.
retrieve() Reads one entry, answering null when it is absent or unreadable. The second argument asks for the write time instead of the value. That field is stored unserialized and comes back as false, so do not build on it.
erase() Removes one entry from the current store. Throws when the store exists and the key does not, so guard it with isCached() or catch.
eraseAll() Deletes the current store's file outright, every entry in it.
clear() Empties whole stores. The argument is a list of store names, or a comma separated string of them. With no argument it wipes every store file.

Store, Key, Lifetime

ArgumentNamesEnds up as
$nameThe storeOne file under the storage cache directory, <name>.cache. Lowercased, and anything outside letters, digits, dot, underscore and hyphen is stripped.
$keyOne entry inside that storeAn array key in that file, holding the serialized value together with its write time and lifetime.
$ttlThe lifetime in seconds3600 for an hour, 86400 for a day of static reference data, 0 for never expires.

What the Producer Must Return

Anything that survives serialize() and comes back equal: arrays, strings, numbers, plain objects. Closures, resources and open connections do not. Declare the return type on the closure so a wrong shape fails where it is written rather than where it is read.

producer shapes
// Fine: an array, and the type says so.
Cache::remember('website', 'tld_requirements', 3600, fn (): array => self::build_requirements());

// Fine: a rendered string.
Cache::remember('website', 'sitemap_' . $lang, 3600, fn (): string => $this->build($lang));

// Fine: several captured values, block body.
Cache::remember('website', 'plans_' . $categoryId . '_' . $ucid, 3600, function () use ($categoryId, $ucid): array {
    $rows = self::plans($categoryId);

    return self::decorate($rows, $ucid);
});

// Broken: null is written but reads back as "missing", so the producer runs
// on every request forever and the cache silently does nothing.
Cache::remember('website', 'maybe_nothing', 3600, fn () => null);

What Belongs in the Key

A cached value is shared by everyone who computes the same key. The key has to name everything the value depends on.

If the value depends onThe key must carryTypical fragment
Money or formattingThe currency being servedMoney::selected()
Any text or linkThe active languageLanguage::selected()
A record, category or filterThat identifier$categoryId
The signed-in visitorNothing, because it must not be cachedNot applicable

Example

the read side
static function catalog_plans(int $categoryId, string $type, string $layout = 'grid'): array
{
    $ucid = Money::selected();

    // Prices depend on the currency and labels on the language, so the key carries
    // both, plus everything that selects the rows.
    $plans = Cache::remember('website', 'plans_' . $categoryId . '_' . $type . '_' . $layout . '_' . $ucid . '_' . Language::selected(), 3600,
        fn (): array => self::catalog_plans_fresh($categoryId, $type, $layout, $ucid));

    // Stock moves with every order, so it is refreshed live over the cached payload
    // rather than being part of it.
    if ($plans) {
        $stocks = self::catalog_stocks(array_map(fn ($p) => (int) $p["id"], $plans));
        foreach ($plans as &$plan) {
            $stock = trim((string) ($stocks[(int) $plan["id"]] ?? ''));
            $plan["in_stock"] = ($stock === '' || (int) $stock > 0);
        }
        unset($plan);
    }

    // Hooks run on EVERY request, so they sit outside the producer. The context goes
    // into a variable first: runRefs takes all of its arguments by reference.
    $ctx = ['category_id' => $categoryId, 'type' => $type, 'layout' => $layout];
    Hook::runRefs('filter:product.catalog_plans', $plans, $ctx);

    return $plans;
}
the other side: whoever edits the value clears it
trait AdminManageWebsite
{
    public function save_menu_changes(Operation $operation): bool
    {
        $operation->demo();

        // ... write the rows ...

        // Targeted: only the 'menus' store, addressed by store name.
        Cache::getInstance()->clear(['menus']);

        return $operation->output(['status' => "successful"]);
    }
}

// Removing a single entry is the other method, and it throws on a miss.
$cache = new Cache('website');
if ($cache->isCached('plans_5_hosting_grid_1_en')) $cache->erase('plans_5_hosting_grid_1_en');

Pitfalls

clear() names stores, erase() names entries

Passing an entry key to clear() does not remove that entry. It treats the string as a store name. It then deletes a file that probably does not exist. The stale entry stays exactly where it was, with no error. Use erase() for one entry, and remember it throws when the key is not there.

Never cache anything that depends on who is asking

Stock levels, a cart, anything behind a sign-in. A cached per-visitor value is served to the next visitor, which is a data leak rather than a stale page.

Keep hooks outside the producer

A hook inside the producer only fires on a cache miss. A listener appears to work while the entry is cold, then silently stops for the rest of the lifetime. Cache the data, then let listeners act on it.

Something has to invalidate it

If an operator can edit the value from a screen, that screen has to clear the store. Newly cached data whose editing screen does not clear keeps serving the old version for the whole lifetime. The report that reaches you will be about the screen, not about the cache.

Was this helpful?

Thanks for your feedback!

Still Need Help?

Our support team is here around the clock for anything you can't find above.