Theme Performance and Caching

2 views Markdown

A theme runs on every page view, so anything it computes twice is paid twice. The cache helper stops that; the key makes it correct.

Overview

Themes are cheap by construction: the template prints values, the controller produces them. The one place a theme can slow an installation down is hooks.php.

The remedy is one call: a bucket, a key, a lifetime and a producer. Two things decide whether it is correct: the key, and what you leave outside it.

Prerequisites

  • A working theme. New to hooks.php? Read Theme Hooks and Output Filters first.
  • One handler per theme for filter:template.variables. Only the last returned value survives.
  • Nothing to switch on; the cache setting is read inside the helper.

Structure

Where Work Belongs

NeedWhere it belongsHow often it runs
Data for one page (this service's invoices) The controller, with the page data Once, on that page only.
Data every page needs (footer categories, a currency strip) The theme's hooks.php, through the variables filter Every website request. The only per-request cost a theme creates.
Presentation (loops, conditions, formatting) The template Every page, and it must stay free. A template that queries has no cache at all.

What Belongs in the Key

The key is the whole contract. Anything the value depends on has to appear in it.

FragmentRead fromLeaving it out produces
Currency Money::getUCID() Prices formatted for whoever warmed the cache, with their symbol.
Language Language::selected() Titles and generated links in one language everywhere. Links are built through the selected language.
Scope of the query The category, product or menu id you passed in One scope's rows served for every scope. The fragment forgotten when a parameter is added later.
Visitor identity Nothing. It never belongs in a shared cache. A key per visitor is a leak, not a cache. Per-visitor values are not cached at all.

Walkthrough

1. Find the Repeated Work

  1. Look at what your handler calls, not what it returns. A plain-looking getter can issue one query per node.
  2. Ask how the value changes. Catalogue data and menus tolerate an hour; stock and carts do not.
  3. Ask who the value belongs to. If the answer names a person, no key makes it cacheable.

2. Wrap the Producer

  1. Put the expensive work in a closure. Computing first and caching afterwards runs it every time.
  2. Build the key from the fragments above, in a fixed order, with a prefix unique to your theme.
  3. Choose a lifetime: an hour for operator-edited data, a day for reference data. Zero never expires.
  4. Add a static guard when one request asks for the same value several times.
the shape
$ucid = (int) Money::getUCID();

$rows = Cache::remember('website', 'acme_footer_' . $ucid . '_' . Language::selected(), 3600,
    fn (): array => Products::group_cards($ucid));

3. Keep Hooks Outside the Cached Region

  1. Cache the data, then filter the result. Inside the producer, a module's listener freezes into the cached copy.
  2. Lift the context into local variables first: the by-reference runner turns a literal into a fatal error.
  3. Follow the shipped precedent: the software store list caches its rows, then filters them.
cache first, filter after
$out = Cache::remember('website', 'software_store_' . $categoryId . '_' . $ucid . '_' . Language::selected(), 3600,
    function () use ($ucid, $categoryId): array {
        // one query, many rows, shaped for the template
        return $this->build($categoryId, $ucid);
    });

// OUTSIDE the producer: this must run on every request, cached or not.
$ctx = ['currency' => $ucid, 'category' => $categoryId];
Hook::runRefs('filter:product.software_list', $out, $ctx);

return $out;

Reference

The Canonical Call

signature
// coremio/classes/Cache.php
// Returns whatever the producer returns: array, string, int, object.
public static function remember(string $name, string $key, int $ttl, callable $producer);
$name The bucket. One file per bucket, loaded and cleared together. Website data uses website; menus use menus.
$key The entry, and the whole correctness contract. Prefix it with your theme, then append every fragment.
$ttl Lifetime in seconds. 3600 for operator-edited data, 86400 for reference data. Zero disables expiry.
$producer Any callable. Runs on a miss, and on every call when caching is switched off. Never add your own check.

The Instance API

Rarely needed. Reach for these to inspect or remove a single entry.

instance methods
// coremio/classes/Cache.php
public static function getInstance(): self;

public function store($key, $data, $expiration = 86400): bool;   // $expiration = 0 never expires
public function retrieve($key, $timestamp = false);              // null when missing or unreadable
public function isCached($key): bool;                            // also drops the entry if expired
public function erase($key): self;                               // drops one entry
public function eraseAll(): self;                                // empties the bucket this instance points at
public function clear($keys = []): void;                         // named buckets, or no argument = every bucket

There is no get() and no set(). A missing or corrupt entry reads back as null, treated as a miss.

Invalidation

TriggerCallWhat it removes
An operator saved something in the panel Already done by the operation that saved it Everything. Most save operations clear the whole store.
You cached something the panel does not know about Cache::getInstance()->clear(['bucket']) The named buckets only. Use this when your theme owns both the write and the read.
Time passing Nothing The entry, on the first read after its lifetime. Expiry is checked on read.
Repeats inside one request are already handled

The bucket file is read once per request, so three keys cost one file read. A static variable also skips the key building.

Example

Product group cards in a footer. The producer is cached, the filter runs outside it, the template only loops.

templates/website/Acme/hooks.php
/*
 * ONE handler per theme: the view layer keeps only the last returned value, so a second
 * registration would drop every key set here. Add keys, do not add a second Hook::add.
 */
Hook::add("filter:template.variables", 1, function ($template, $data) {
    $data["footer_groups"] = acme_footer_groups();

    return $data;
});

if (!function_exists('acme_footer_groups')) {
    /**
     * Product group cards for the footer strip. Runs on every website page, so the query
     * behind it is cached; the value is shared by every visitor, which is exactly why the
     * key has to carry the two things that make it visitor-specific.
     */
    function acme_footer_groups(): array
    {
        // Same request, several partials: skip even the key building.
        static $rows = null;
        if ($rows !== null) return $rows;

        // Currency comes from the visitor, links come from the selected language.
        // Both go in the key, or the first visitor's version is served to everyone.
        $ucid = (int) Money::getUCID();

        $rows = Cache::remember('website', 'acme_footer_groups_' . $ucid . '_' . Language::selected(), 3600,
            fn (): array => Products::group_cards($ucid));

        return $rows;
    }
}
partials/footer.tpl
{if $footer_groups}
    <ul class="footer-links">
        {foreach $footer_groups as $g}
            <li><a href="{$g.link}">{$g.title}</a> <span class="text-body-secondary">{$g.price}</span></li>
        {/foreach}
    </ul>
{/if}

The template does no lookup and no formatting: the price arrived formatted.

Cache::remember() The only cache call a theme needs. Falls through to the producer when caching is off.
Money::getUCID() The visitor's currency id. Belongs in the key of anything that produces a formatted amount.
Language::selected() The selected language code. Belongs in the key of anything that produces text or a generated link.
Products::group_cards() The shipped producer used above; it caches internally with the same two fragments.

Pitfalls

A key without the currency is wrong on a machine you never test on

The cache is warmed by whoever loaded the page first; locally that is always you. The same applies to the language.

A hook inside the producer runs once an hour instead of once a request

A filter inside the closure is applied only when the entry is rebuilt. The module works right after a clear and stops a minute later.

The by-reference hook runner takes every argument by reference

That includes the context after the filtered value: a literal or a cast is a fatal error.

Never cache what belongs to one visitor

Stock levels, cart contents, anything behind a login: none of it goes through a shared store. Wrong for the second reader means data exposure.

Register the variables filter once per theme

Only the last returned value survives, so a second registration drops every key the first one set. The symptom is template variables going empty at once.

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.