Theme Performance and Caching
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
| Need | Where it belongs | How 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.
| Fragment | Read from | Leaving 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
- Look at what your handler calls, not what it returns. A plain-looking getter can issue one query per node.
- Ask how the value changes. Catalogue data and menus tolerate an hour; stock and carts do not.
- Ask who the value belongs to. If the answer names a person, no key makes it cacheable.
2. Wrap the Producer
- Put the expensive work in a closure. Computing first and caching afterwards runs it every time.
- Build the key from the fragments above, in a fixed order, with a prefix unique to your theme.
- Choose a lifetime: an hour for operator-edited data, a day for reference data. Zero never expires.
- Add a static guard when one request asks for the same value several times.
$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
- Cache the data, then filter the result. Inside the producer, a module's listener freezes into the cached copy.
- Lift the context into local variables first: the by-reference runner turns a literal into a fatal error.
- Follow the shipped precedent: the software store list caches its rows, then filters them.
$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
// 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);
website; menus use menus.
The Instance API
Rarely needed. Reach for these to inspect or remove a single entry.
// 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
| Trigger | Call | What 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. |
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.
/*
* 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;
}
}
{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.
Pitfalls
The cache is warmed by whoever loaded the page first; locally that is always you. The same applies to the language.
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.
That includes the context after the filtered value: a literal or a cast is a fatal error.
Stock levels, cart contents, anything behind a login: none of it goes through a shared store. Wrong for the second reader means data exposure.
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.
Related Articles
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.