Caching

Keep a page, a fragment or a region of a template on the server. Choose who shares an entry, what splits it and what clears it.

On this page

Caching is opt-in. Nothing is kept until a view, fragment, component or region asks for it, and a write to the content it shows clears it. There are three server-side layers, each narrower than the last:

Layer Keeps Asked for with
Page A whole rendered page cache in the view's _index.json
Fragment One {block} of a page, fetched at its :blockname URL {fragment} or {component} with cache:
Region Part of a template, inside a normal render {cache}

All three store HTML in site/data/cache/fragments.db and share the same rules for who sees an entry, what splits it and what clears it. Files and images have their own browser caching, covered at the end.

Cache what you have measured as slow. A cache that is not needed is one more thing to reason about.

Caching a fragment

Give a {fragment} a cache: in seconds. Its HTML is stored and served without running the controller or the rest of the page:

{fragment 'latest', cache: 300}
    {foreach $page->children('sort=-created, limit=5') as $post}
        {include 'blog/card.latte', p: $post}
    {/foreach}
{/fragment}

{fragment 'latest'} is {block} and {partial} in one tag, so the block is also reachable at {$page->url()}:latest. Another page embeds it by fetching that URL, and it lands in the element whose id matches the name:

<div
    id="latest"
    hx-get="{p('blog')->url()}:latest"
    hx-trigger="load"
    hx-swap="none"
></div>

{component} takes the same cache:. It is never rendered in its own page and has no {partial}, so the element that fetches it decides where it lands. Use it for a piece many pages share:

{component 'latest', cache: 300}
    {foreach $page->children('sort=-created, limit=5') as $post}
        {include 'blog/card.latte', p: $post}
    {/foreach}
{/component}

<aside
    hx-get="{p('blog')->url()}:latest"
    hx-trigger="load"
></aside>

Only blocks written directly in the target page's own template can be fetched this way, not blocks inside an {include}d file. If the target's view has a router, it needs a get('') route.

Caching a region

{cache} keeps part of a template inside a normal render. The key is the page URL and query:

{cache 'stats', 600}
    {include 'shop/stats.latte', orders: $orders}
{/cache}

key: replaces the address so several pages share one region. A shared region also needs in: (see below), because the page that holds it must be cleared whenever any of them could change:

{cache 'nav', 300, key: $section->uuid, in: '/'}
    {include '@ui/menu.latte', items: $section->children()}
{/cache}

A hit skips the region entirely, so nothing inside it may set a response header or a variable used after it.

Caching a page

Set cache in a view's _index.json to a number of seconds and its pages keep their whole render:

{ "cache": 300 }

A kept page is served before the router, the controllers and the template run. Use it on a view whose GET routes have no side effects. Only plain GET requests are kept.

By default the home page holds the entry, so any page write clears it. To tie it to its own branch, write the setting as an object. It is then cleared by a write to the page, a page above it or a page below it:

{ "cache": { "ttl": 300, "scope": "page" } }

With scope: page, content from other branches, such as a menu or a list of related pages, belongs in a {component} held by a common ancestor.

A response that started a session, set a cookie or set an HX-* header is not kept. The TTL is cut to the shortest {cache} region inside the page. The response carries Cache-Control: no-cache and an ETag, so a browser or proxy checks with the server on every use and gets an empty 304 while the page is unchanged. It can never serve a stale copy.

Who sees an entry

Every entry is kept for an audience, set with per:

per One entry for Use it for
user (default) Each signed-in user. Signed-out visitors share one. Anything shaped by who is looking: an account panel, a feed, an API call made with the user's token
role Each role. guest is a role. A menu or dashboard that differs by role, not by person
none Everyone Public content that is the same for any visitor

The default is the safe one: two users never see each other's entry unless the view says they may. Widening it is the opt-in:

{* One copy for every visitor *}
{fragment 'prices', cache: 3600, per: 'none'}
    {include 'shop/prices.latte'}
{/fragment}

{* One copy per role *}
{component 'toolbar', cache: 300, per: 'role'}
    {include 'shop/toolbar.latte'}
{/component}

{* One copy per signed-in user, the default *}
{cache 'orders', 120}
    {include 'shop/orders.latte'}
{/cache}

On a page, write it in the view's _index.json:

{ "cache": { "ttl": 300, "per": "role" } }

A key: on {cache} replaces only the address. per still applies, so a shared key does not share the entry across users.

A signed-in visitor's kept page is sent private, no-cache, so a shared proxy never holds it.

A wrong per costs a miss, not a leak

PhlatPage watches what a render reads. A render is not kept when it read something its per does not share:

The render read Refused under
The session (a flash message, a CSRF token) Any per
The signed-in user, $app->session->user() role and none
The role: role(), can(), isLoggedIn() none

So per: 'none' on a block that greets the user by name is rendered every time and the reason shows in Clockwork's Cache panel. The fix is to keep the expensive part and leave the personal part outside it:

<p>Hello, {$user->name}.</p>

{fragment 'catalogue', cache: 3600, per: 'none'}
    {foreach find('view=product, limit=24') as $product}
        {include 'shop/card.latte', p: $product}
    {/foreach}
{/fragment}

A form with a CSRF token, or a block that shows a flash message, stays outside the cached region for the same reason.

What splits an entry

An entry is kept once per value of whatever the render read, and nothing else:

  • Query parameters read through $app->request->query('page'). A parameter nothing read, such as ?utm_source=, creates no new entry. queries(), which reads all of them, keys on the full sorted query.
  • Visitor options read through $app->options->get('theme'). Visitors who chose dark share one copy, and an option the page never reads does not split it.
  • htmx. A partial request has an entry apart from a full page.

Read through those methods, never $_GET or $_COOKIE. A template that reads a superglobal directly is invisible to the cache, and its entry is keyed too coarsely.

What clears an entry

Every entry belongs to a page, and the page tree is the dependency model. Writing a page clears its own entries, the entries of every page above it and everything below it. The home page is above everything, so an entry held there clears on every write. There are no keys to manage.

Event Clears
A page is created, saved, moved or deleted That page's entries, those of its ancestors and those of its descendants
A collection item or file is written, or a History restore The page that owns it and the pages above it
A page move or a History sync Every entry
A user is updated or deleted Only the entries kept for that user
Config, templates or code change Nothing. The entry ends when its TTL does

After a deploy that keeps site/data/cache, run phlat cache:clear fragments. The reference deploy.php gives each release an empty cache.

Holding an entry higher with in:

A fragment that lists pages outside its own page's subtree would stay stale until its TTL ran out. in: names the page that holds the entry, as a literal path, so a write anywhere under that page clears it:

{fragment 'latest', cache: 300, in: '/'}
    {foreach find('view=article, sort=-created, limit=4') as $article}
        {include 'article/card.latte', p: $article}
    {/foreach}
{/fragment}

in: '/' is cleared by every page write. A narrower page such as in: '/blog' is cleared only by writes under /blog. A path that names no page holds nothing, so the region renders every time.

Clearing from an event

A user write clears only that user's entries. A page that shows users to others, such as a team page, is not one of them, so clear it from a hook:

$app->on('user.update:after', fn() => $app->page('/team')->cache()->flush());

Any event works the same way. $page->cache()->flush() empties one page's own entries, and $app->cache('fragments')->flushTree($page->url()) empties the entries a save of that page would clear. From the shell, phlat cache:clear --page=blog/my-post does the same, with --tree for the wider clear.

Example: a members area with an API

A members page shows the signed-in user's orders from a billing API. The call is slow, the data is private to the user and a minute of staleness is fine. The default per is exactly right, so the block only needs a TTL.

The call lives on the view's Page class, so it runs only when the template asks for it:

<?php
// site/views/members/members.php

declare(strict_types=1);

namespace Phlat;

use Phlat\Toolkit\Http;

class MembersPage extends Page
{
    public function orders(): array
    {
        $user = $this->app->session->user();

        return Http::get(
            "https://billing.example.com/orders?customer={$user->uuid}",
            ['Authorization' => 'Bearer ' . $this->app->config->get('billing.token')],
        )->json();
    }
}

The template holds the orders in a {component} and loads it with hx-trigger="load". A component is never rendered in the page itself, so opening the page makes no API call:

<h1>Your orders</h1>

<div
    hx-get="{$page->url()}:orders"
    hx-trigger="load"
></div>

{component 'orders', cache: 60}
    <ul>
        <li n:foreach="$page->orders() as $order">{$order['number']}: {$order['status']}</li>
    </ul>
{/component}

Each member gets an entry of their own for a minute. A hit is served before the controller or the template run, so the API is called once per member per minute however often the page opens.

When the data changes on the billing side, the member sees it after the TTL. If your site is told about the change, clear the entry from that handler with $page->cache()->flush() and the next request fetches fresh data.

A page both for members and visitors, with a list that is the same for everyone, splits the work: keep the shared list with per: 'none' and the member's own block with the default.

Watching and switching it off

Clockwork's Cache panel lists every get, set and flush with its key, and a row saying why a kept page was or was not stored. The same events are available to your own code as cache.get:after, cache.set:after, cache.flush:after, page.cache:hit and page.cache:skip.

"cache": false in config.json or env.json turns every cache off, and cache.fragments.enabled turns off one. Both are useful while developing a template. phlat cache:clear empties site/data/cache, and phlat cache:clear fragments empties only this store.

Files and images

Image variants are sent public, max-age=31536000, immutable. A file's url() carries a token from its modification time, so replacing the file changes the URL and the browser fetches the new one. Other files use the files.cache setting. These are browser caches and need no markup.