# Theme Performance and Caching

https://dev.wisecp.com/es/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](https://dev.wisecp.com/en/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

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.

```php
$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.

```php
$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

```php
// 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.

```php
// 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. |

> **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.

```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;
    }
}
```

```smarty
{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.

## Related Articles

- [Caching](https://dev.wisecp.com/en/caching)
- [Theme Hooks and Output Filters](https://dev.wisecp.com/en/theme-hooks-and-output-filters)
- [Template Variables](https://dev.wisecp.com/en/template-variables)
- [Menus and Navigation](https://dev.wisecp.com/en/menus-and-navigation)
