Skip to content

Caching

Quiote has two distinct caching layers, and it helps to keep them apart from the start:

  1. The action/view cache — stores the rendered result of an action so a repeat request can skip execution entirely. This is the one you reach for to make a page fast, and it is driven by an isCacheable() flag on the action.
  2. The general-purpose cache — a PSR-16 key/value store (CacheManager) you use directly for anything you want to memoize: a computed value, an API response, a query result.

Both are off by default. Caching is opt-in because the framework will not guess that your output is safe to reuse.

The action/view cache is gated by two settings — both must be true for it to build at all. This is deliberate (a master switch plus a per-environment switch), and it is an easy thing to get wrong, so check both:

Config/settings.php
return [
'core.cache_enabled' => true, // master switch (default false)
'core.use_cache' => true, // required companion flag (default false)
'core.cache_backend' => 'filesystem', // 'filesystem' (default), 'apcu', or 'redis'
'core.cache_dir' => __DIR__ . '/../cache',
];
SettingDefaultEffect
core.cache_enabledfalseMaster switch for the action/view cache.
core.use_cachefalseCompanion flag; both it and cache_enabled must be true.
core.cache_backendfilesystemfilesystem, apcu, or redis. apcu only takes effect if apcu_enabled().
core.redis_dsnredis://127.0.0.1:6379Connection DSN, when core.cache_backend is redis.
core.cache_dir<app>/cacheBase directory; the PSR cache pool lives under <cache_dir>/psr-cache.

There is no core.cache_ttl — time-to-live is set per action (below).

filesystem and apcu are both node-local: a multi-node deployment gets one cache per node, and an entry written on one is invisible to the others. That is usually fine for an output cache and a real problem for anything you need to invalidate consistently. redis is the shared option — the cache is visible to every worker and every node.

Set core.cache_backend to redis and core.redis_dsn to the connection string. It needs a Redis client available (ext-redis, ext-relay, or predis/predis) and raises a clear exception naming the setting if none is — it does not silently fall back. See Redis backends.

Note that apcu does silently fall back to filesystem when apcu_enabled() is false at runtime, which is easy to hit on a CLI SAPI with apc.enable_cli=0.

An action opts in by returning true from isCacheable() and, optionally, a lifetime from cacheTtlSeconds():

class ShowPostAction extends Action
{
public function isCacheable(?string $outputType = null): bool
{
return true;
}
public function cacheTtlSeconds(?string $outputType = null): ?int
{
return $outputType === 'rss' ? 600 : 60; // null = default 300s
}
public function executeRead(WebRequest $rd)
{
$this->setAttribute('post', /* ... */);
return 'Success';
}
}

Both defaults on the base Action are conservative: isCacheable() returns false, cacheTtlSeconds() returns null (which falls back to a 300-second default). The output type is passed in, so you can cache HTML but not JSON, or give each a different TTL.

If you just want the common case — cacheable, 300s — apply the trait instead of writing both methods:

use Quiote\Action\Traits\CacheableActionTrait;
class ShowPostAction extends Action
{
use CacheableActionTrait; // isCacheable() => true, cacheTtlSeconds() => 300
}

You do not compose cache keys yourself — there is no getCacheKey() hook. ActionViewCache builds the key from the module, action, output type, two namespace version counters, and (for secure actions) a user fingerprint:

av:<moduleVersion>:<moduleActionVersion>:<module>:<action>:<outputType>[:u:<fingerprint>]

The user fingerprint is only added when the action’s isSecure() returns true, so a public page shares one cache entry across everyone while a secure page gets a per-authentication-state entry. This is what keeps caching from serving one user’s page to another. See Authentication and authorization.

The cache is consulted by DispatchMiddleware, the middleware that runs the action. The kernel discovers it like any other middleware (the MiddlewareAttributeScanner scans #[Middleware] attributes, the MiddlewareOrderResolver orders them); DispatchMiddleware sits in the action phase, after routing, security, and validation. Its cache logic:

DispatchMiddleware builds the cache key → looks it up in ActionViewCachehit: replays the stored view result and skips the action entirely → miss: runs the action, renders the view, then stores the result with your TTL → response is emitted.

The lookup only happens when both core.cache_enabled and core.use_cache are true and the action’s isCacheable() returns true; otherwise the action always runs. For the full pipeline see Request lifecycle and Middleware pipeline.

Because you can’t vary the key, the way you expire a cached action is to bump the namespace it lives in. CacheManager exposes targeted invalidation:

use Quiote\Cache\CacheManager;
CacheManager::invalidateAction('Blog', 'ShowPost'); // expire one action's variants
CacheManager::invalidateModule('Blog'); // expire the whole module

Call these from the action that writes the data — e.g. after saving a post, invalidate the action that displays it — so readers see fresh output before the TTL elapses.

For everything that isn’t action output, use CacheManager::getCache(), which returns a PSR-16 cache:

use Quiote\Cache\CacheManager;
$cache = CacheManager::getCache();
$value = $cache->get('report:daily');
if ($value === null) {
$value = $this->buildExpensiveReport();
$cache->set('report:daily', $value, 3600); // TTL in seconds
}

The full PSR-16 surface is available (get/set/has/delete/clear, and the *Multiple variants). The backend is the one selected by core.cache_backend — Symfony’s filesystem adapter by default, APCu or Redis when configured and available.

CacheManager::setCache() lets you supply any PSR-16 implementation — a Redis client, an in-memory fake for tests, or FileCache pointed at a specific directory:

CacheManager::setCache(new \My\RedisPsr16Cache($redis), 'redis');

Under worker mode, CacheManager keeps an in-process memo of namespace version numbers. That memo is not cleared by the per-request worker reset, so a version bumped by one worker process is not automatically seen by another until its memo refreshes. In practice:

  • Single-worker deployments are unaffected.
  • Multi-worker deployments should not rely on invalidateAction()/invalidateModule() being instant across every worker. Lean on TTLs for cross-worker freshness, or call CacheManager::reset() where you need a hard refresh (this is also the correct isolation call in tests).

Alongside the isCacheable() method, a module can declare caching for an action in an XML config file under the module’s Cache/ directory (Modules/{Module}/Cache/{Action}.xml). It is handled by the caching config handler and compiles to the same cacheability decision:

Modules/Blog/Cache/ShowPost.xml
<caching lifetime="5 minutes" enabled="true">
<group source="locale">locale</group>
<view module="Blog">Success</view>
<output_type name="html">
<template_variable>*</template_variable>
</output_type>
</caching>

This is a declarative alternative to the method-based approach — useful when you want caching described in config rather than code, and it carries over cleanly from Agavi’s per-action cache config. For new code the isCacheable() / CacheableActionTrait path is simpler; use whichever fits your team.