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;
}
}
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
Grazie per il tuo feedback!
Il nostro team di assistenza è disponibile 24 ore su 24 per aiutarti a trovare le risposte che cerchi.