Page Cache Guide¶
Pramnos\Cache\Page\PageCache serves whole rendered pages back to anonymous and
crawler traffic, so the second visitor to a page does not pay for rendering it.
It is off by default, and every default it does have is the one that caches less rather than more. That is deliberate: the failure mode of a page cache is serving one visitor's page to another, and it is silent.
Turning it on¶
Three things — a middleware in the pipeline, a pagecache block in app.php, and
a front controller that returns a Response.
// app.php
'middleware' => [
\Pramnos\Http\Middleware\PageCacheMiddleware::class, // first
\Pramnos\Http\Middleware\SessionTrackingMiddleware::class,
\Pramnos\Http\Middleware\AuthMiddleware::class,
],
'pagecache' => [
'enabled' => true,
'store' => 'redis',
'ttl' => 3600,
],
Register it first. The reason a page cache is fast is not the storage — it is how little has run by the time it answers. A middleware that starts a session in front of it has already spent most of what it saves.
With no pagecache block at all the middleware is inert: it looks nothing up and
stores nothing. Adding it to a pipeline can never, by itself, change what
visitors see.
The store is the page cache's alone. Its adapter is built with the prefix, so clearing it
(flush(), pagecache:purge --all) removes the pages and nothing else in the same Redis
database or cache folder. Redis is reached as Cache reaches it: the page cache's own
'redis' => ['host' => …, 'port' => …, 'database' => …, 'password' => …], else the cache
settings' hostname, else the shared connection (REDIS_HOST / REDIS_PORT). On a Docker
network localhost is nobody, and every request would miss.
Where the block goes. app.php is read first, then Settings — so a row in
the settings table works too, and app.php wins if both exist. app.php is the
better place: it is in the repository and reviewable, and reading it costs nothing,
where a settings lookup for a key the store does not hold falls through to a
database query the cache exists to avoid.
The pipeline has to return a Response¶
store() needs a Response — something with a status, headers and a body to keep,
and something to hang X-Pramnos-Cache on. Application::render() returns a
string, so a front controller that ends with
hands the middleware a string on the way back out, and a string is left alone: there is no reliable body to store and guessing at one would cache half a page. Wrap it:
$response = $pipeline->run(
$request,
fn () => \Pramnos\Http\Response::make((string) $app->render())
);
$response->send();
This is worth checking first, because the symptom is identical to a pagecache
block that was never read: no X-Pramnos-Cache header, nothing stored, no error.
What gets cached, and what never does¶
A request is served from cache only if all of these hold. They are checked in this order, cheapest first.
| # | Rule | Config |
|---|---|---|
| 1 | The cache is enabled | enabled |
| 2 | The method is safe | methods — default GET, HEAD |
| 3 | The host is one we cache | hosts — default: all |
| 4 | The path is in the allow-list, if there is one | onlyPaths |
| 5 | The path is not excluded | bypassPaths |
| 6 | No excluded query parameter is present | bypassQuery |
| 7 | No authentication or session cookie is present | bypassCookies |
| 8 | No authentication header is present | bypassHeaders |
PageCache::bypass() is checked as part of that list, so it applies to both
halves: a bypassed request is neither stored nor answered from the cache.
A response is stored only if the request passed all of them and:
- its status is on the list —
statuses, default[200]only; - it does not carry
Set-Cookie; - its body is not empty;
- its body contains no
privateMarkersstring; - its body does not contain this response's CSP nonce — see below;
- its body does not contain the visitor's id — see Nothing about the visitor in a stored page;
- the debug toolbar is not collecting —
skipWhileDebugging, defaulttrue.
Set-Cookie is refused outright rather than filtered away, because a response
that is setting a session is per-visitor in its body too. The tracking cookies are the
exception (harmlessCookies), and their values are looked for in the body instead.
Nothing about the visitor in a stored page¶
A stored page is the same bytes for everybody who asks for it until it expires. Anything in it that says who the visitor is — an id for a tracker, a visit number, a name — is then the first visitor's, handed to all the rest. And the first visitor of a page is often a bot.
The tracking cookies (harmlessCookies, visitorid and lastseen by default) may go with a
stored page, because a cookie is the browser's own. Their values may not be in its body. So
store() refuses a body that contains the session's visitorid, or the value of a harmless
cookie the same response sets. A value that is only digits is not looked for: lastseen holds
time(), which identifies nobody and which a page may well print for its own scripts. A page that wants to tell a script who the visitor is cannot be
shared; let the script read its own cookie instead.
A HIT records no visit¶
PageCacheMiddleware answers a hit before SessionTrackingMiddleware runs, and
PageCache::serveEarly() before the application starts at all. That is what makes a hit fast,
and it means a visitor who only ever receives cached pages has no sessions row. Two ways to
count them, by what each costs:
- A beacon from the page, to an address the cache does not answer. Only
GETandHEADare cached, so aPOSTalways passes through the session tracking, which reads the browser's ownvisitoridcookie or mints one:
// a route of your own; SessionTrackingMiddleware has done the work by the time it runs
$r->post('/visit', static fn () => ['status' => 204]);
- Tracking before the cache:
SessionTrackingMiddlewarefirst in the pipeline. Every hit then starts a session and writes its row, which is most of what the cache saved.
The debug toolbar stops pages being stored¶
Application::render() injects the toolbar into the HTML it returns, and a front
controller that wraps that string in a Response hands the toolbar to store()
along with the page. Nothing in between can tell: privateMarkers is empty by
default and a toolbar sets no cookie.
What would be stored is one developer's SQL with its bound values, their timings and the files that ran — then served to everyone who asks for the page next. So the cache refuses while any collector is registered, which is the same condition that decides whether a toolbar is injected at all.
APP_DEBUG is meant to be off in production, which bounds this and does not close
it: a staging environment with real data and the cache on is an ordinary thing to
have, and there the failure is silent.
Turn it off only somewhere the stored pages cannot reach anybody else.
There is no toolbar on a cache hit
A hit returns a Response before the application runs, and
DebugBarMiddleware only decorates string responses — so a hit carries no
toolbar at all. That is the correct outcome and an easy one to misread as
"debug is broken". X-Pramnos-Cache in the response headers is what tells you
a hit happened.
The session is never started — but the session cookie is read¶
Not starting one is what lets the cache answer before the application boots. Every rule above reads the request and nothing else: method, host, path, query, cookies, headers.
The cookie is enough. A request carrying one is a visitor the server already holds
state for, so the session cookie is in bypassCookies by default:
'bypassCookies' => [
'#^(auth|remember|logged)#i',
'#^PHPSESSID$#i', // session_name(), whatever yours is called
],
Serving that visitor a page stored for somebody else, or storing their response for
everybody, are both wrong, and the second is the dangerous direction. The first
pattern alone does not match PHPSESSID, so an application whose signed-in state
lives only in $_SESSION would have nothing on this list to stop it — and a
signed-in response that sets no cookie is not caught by the Set-Cookie rule either.
What makes this a workable default is 'session' => 'lazy': with it an anonymous
reader carries no session cookie at all, so bypassing on one costs no cache hits.
Without lazy sessions the framework sets PHPSESSID on every response and store()
refuses those anyway, so the pattern costs no caching in either mode.
If you want signed-in visitors served from cache, drop the pattern and lean on
privateMarkers or varyBy instead — but understand that you are then promising
that the page is byte-identical for everybody who can reach it.
A cache hit and the CSP nonce¶
The framework sends a Content-Security-Policy whose default is default-src
'none', with a per-response nonce (Application::$cspNonce) stamped into every
inline <script> and <style> it writes. Two things follow, and the second is the
one that decides whether your pages are cacheable at all.
A hit still gets a policy. sendCspHeader() is called from
Application::render(), which a hit never reaches — left alone, a cached page would
go out with no policy at all: a page that looks perfect and whose scripts run
because nothing is left to stop them. So PageCacheMiddleware builds a fresh policy
and attaches it to the hit. Nothing to configure.
A page with a nonced inline script is never stored. A stored body freezes the
nonce it was rendered with and hands that same one to every visitor for the whole
TTL — and a nonce that is reused is not a nonce; its entire property is being
unguessable per response. Replaying the stored header would be that bug; not
replaying it was the first one. So the two features are mutually exclusive for such
a page, and store() takes the side that fails loudly:
| outcome | |
|---|---|
| store the body with its nonce | one predictable nonce, shared for the TTL |
| store it and re-send a fresh policy | the policy matches nothing in the body |
| refuse to store it (what happens) | the page is simply never cached |
The way to make such a page cacheable is to render it without a per-response nonce: move the script to a file, use a hash-based policy, or drop the inline script. That is a decision only the application can make, which is why the framework will not make it silently.
Which of your inline scripts actually needs a nonce¶
Fewer than you would think, and the framework does not spend one where it cannot be spent.
A <script> whose type is not JavaScript gets no nonce. script-src gates script
execution, and a <script type="application/ld+json"> is a data block the browser
never runs — there is nothing for the policy to allow, so a nonce would be inert.
Structured data has to be inline for a crawler to read it, so a nonce there would often
be the only thing keeping an otherwise static page out of the cache.
importmap and speculationrules do keep theirs. They look like data and are not: an
import map needs an inline allowance like any other script, and speculation rules are
gated by script-src so specifically that CSP has an 'inline-speculation-rules' keyword
for them. The rule is an allow-list of executable types, because being wrong in that
direction costs an unnecessary nonce and being wrong the other way costs a working page.
Inline <style> keeps its nonce. style-src genuinely gates inline styles, so a page
with an inline <style> is uncacheable for the same reason as one with inline script.
The framework's own no-js flip is allowed by hash. The 96 bytes in <head> that
turn class="no-js" into js are a fixed string, so its 'sha256-…' is in script-src
and the tag carries no nonce. It stays inline because it has to run before the first
paint — a blocking request in <head> to find out whether JavaScript exists is the very
thing the no-js class exists to avoid.
Between them, that means a scaffolded page with no inline script of its own is cacheable out of the box, rather than after an audit of the framework's markup.
The symptom, if you have not read this far: X-Pramnos-Cache: MISS on every single
request to a page that passes every bypass rule. whyBypassed() returns null for
it, correctly — this is a property of the response, not of the request.
The static-file writer sends no policy
With 'writer' => 'static' a rewrite rule serves index.html and PHP never
runs, so nothing framework-side can send a header. Send the policy from the
web server:
A static policy is exactly what a server directive does well — and it is all
that is needed, because the nonce half is provably absent from anything stored.
serveEarly() does send one; it reads
the csp block from app.php for it.
The key¶
Two requests share a cached page when they agree on: method (with HEAD folded
into GET), scheme, host, path, the filtered and sorted query string, and
the varyBy values.
Tracking parameters are dropped before keying, and what remains is sorted. Both matter more than they look:
?a=1&b=2and?b=2&a=1are one entry, not two.- Every campaign link would otherwise be a permanent miss — advertising traffic is exactly the traffic a page cache is for — and anyone could fill the store by appending junk parameters.
If you know precisely which parameters matter, name them instead. varyQuery is
a whitelist and everything else is discarded:
The query is the one the request arrived with. lookup() keeps it, and store() keys
from it, so a controller that rewrites $_GET while it renders still stores its page where the
next lookup finds it.
Logged-in and anonymous do not vary the key. A logged-in request is never served from cache and never stored, so one key means one public view; splitting it would only create an entry nothing can reach. Crawlers share the anonymous key too, and differ only in TTL.
varyBy — when one URL really is several pages¶
Each distinct value gets its own entry. Keep the set small: two values double the storage and halve the hit rate, and a resolver returning something unbounded — a user id, a timestamp — gives every visitor their own entry, which is a cache that only ever misses.
TTL¶
'ttl' => 3600,
'ttlRules' => [
'/news*' => 60, // first match wins
'/stations/*' => 7200,
],
'botTtl' => 86400, // crawlers only
ttlRules accepts globs or delimited regexes — '/api/*' and '#^/api#' both
work, because configuration gets written by people who think in each.
botTtl is worth setting. Crawlers are the traffic least harmed by a stale page
and the most likely to ask for pages no human has requested in hours.
Refusing to cache, from inside the render¶
use Pramnos\Cache\Page\PageCache;
if ($cart->hasItems()) {
PageCache::bypass('cart is not empty');
}
Static, because the caller is a controller, a view or a model that has no reference to the cache — the same reason WordPress uses a constant for this.
The alternative some implementations use — emit a marker into the HTML and grep
the finished body for it — also works, and answers after the whole page has been
built, in a string search over the entire document, and silently stops working if
the marker is ever reworded. privateMarkers is available for cases where you
cannot reach the render, but prefer bypass() where you can:
Invalidation¶
Without tags the only invalidation is the clock, which means "the correction appears within an hour". That is why full-page caches get switched off after the first urgent edit.
// when the data changes
$cache = new PageCache($config);
$cache->purgeTag('station:7');
$cache->purgeUrl('https://example.test/stations/7');
$cache->flush();
From the command line:
./pramnos pagecache:purge /stations/7
./pramnos pagecache:purge --tag=station:7 --tag=homepage
./pramnos pagecache:purge --all
A bare path is resolved against the configured site URL, because entries are
keyed by absolute URL — one installation can answer on several hosts. An application
that keeps the page cache's configuration in a file of its own names it, so the command
purges the store the pages are in: --config=app/config/pagecache.php, a PHP file that
returns the array.
purgeUrl() removes every variant of that address, not only the one matching
the purging request. An invalidation that cleared one varyBy variant would leave
the others serving the old page to exactly the visitors who see them.
Why tags cost what they cost¶
Each tag keeps an index of its own entry keys, so a purge reads that tag's members and deletes them — the size of the tag.
The obvious alternative is asking the store for every key matching a pattern, and
on Redis that is a trap: SCAN walks the entire keyspace whatever the MATCH
says, because MATCH filters what comes back rather than what is traversed. A
per-record purge built that way costs the size of the whole database. The
Cache Guide has the
measurements.
Stale-while-revalidate, and the lock¶
When an entry expires, the first request past expiry re-renders and everybody arriving in the next 30 seconds is served the old copy rather than piling onto the same render.
The lock is chosen by asking the store, not by assuming. The framework's own
Cache::supportsAtomicCounter() exists because the File and Array adapters
implement increment() as a load followed by a save — under concurrency every
caller reads the same value and every caller believes it won. A stampede lock
built on that fails at the only moment it is for.
So there are two implementations, chosen by supportsAtomicCounter():
| Store | Lock |
|---|---|
| Redis | swap() — Redis GETSET, one server-side operation |
| Memcached | swap() too: add when nobody holds the lock, cas when somebody does — each fails if another request wrote in between, so exactly one takes it. |
| File, Array | a mkdir() lock — atomic on Linux, macOS and WSL alike |
Setting lockTtl to 0 means a lock is never honoured and every arrival
re-renders. Only sensible if renders are cheap enough not to need protecting.
ETag, gzip and the debug header¶
etag and gzip are on by default.
- A matching
If-None-Matchis answered with a 304 and no body — the cheapest possible hit. The 304 carries theETag, as RFC 7232 §4.1 requires.If-None-Matchis parsed rather than compared: a list ("a", "b"), a weak validator (W/"a") and*are all understood. - The gzipped copy is built once at store time, not per hit, and served to clients that accept it. After the render itself this is most of the CPU a page cache saves.
'compressAtRest' => truekeeps only the gzipped copy, about a sixth of the size: a store short of memory holds six times the pages. A client that does not take gzip (a bot,curl) has it unzipped on the way out, and theETagis the unzipped body's either way.-
With
gzipon,Vary: Accept-Encodingis always sent — on the compressed and the uncompressed response alike. One URL then has two bodies, and any shared cache in front of the application must be told, or it will store one variant and hand it to a client that asked for the other. A client that sent noAccept-Encodingreceiving compressed bytes is the classic "broken for some people only" report, and it never reproduces locally.A
Varyyour application already sends is merged, not replaced. Withgzipoff nothing is added, because there is then one body per URL and a needlessVarycosts hit rate in every cache downstream.If your web server already compresses —
mod_deflate,gzip on— prefer'gzip' => falseand let it do the work; it emits the correctVaryitself, and you avoid storing two copies of every page. -
X-Pramnos-Cache: HIT | STALE | HIT-304andAge:are sent whiledebugHeaderis true. Leave it on — it is how you find out the cache is not working — and turn it off if you would rather not advertise the arrangement.
When you need to know why¶
'debugDetail' => true adds three more:
| Header | |
|---|---|
X-Pramnos-Cache-Key |
the key the entry is actually stored under |
X-Pramnos-Cache-TTL |
its lifetime in seconds |
X-Pramnos-Cache-Expires |
when it dies, as an HTTP date |
The key is the useful one. When a page is not cached the way you expect, the
question is almost always "under what key did it go in?" — and with ignoreQuery,
varyBy and varyQuery all feeding it, not being able to see the key means
debugging by guesswork.
Off by default, unlike debugHeader. HIT and Age are ordinary things for a
cache to say; the key is internal, and publishing it to every visitor hands anybody
probing for cache-key collisions the normalisation rules for free.
Headers rather than an HTML comment: a body is what snapshot tools diff and what a search engine indexes, and debug information does not belong in a stored page.
Serving before the application boots¶
// www/index.php
define('ROOT', dirname(__DIR__));
require ROOT . '/vendor/autoload.php';
\Pramnos\Cache\Page\PageCache::serveEarly(); // hit ⇒ sends and exits
Safe this early precisely because the decision reads only the request. This is where the large savings are: not the storage lookup, but everything that does not run behind it.
No argument. It reads app.php itself — a require of an array literal, which
is not the bootstrap this method exists to skip: what it skips is Application::init()
and its database, session, language and theme.
Reading the same block is the point: a copy of the pagecache rules in index.php
would be a second declaration, and the one that answers first — change bypassCookies
in app.php, forget the copy, and the early path keeps serving a signed-in page to
everybody from a rule set that exists nowhere else. Passing a config is still accepted
and wins, for a caller that wants a different one here on purpose.
ROOT has to be defined before the call, which every scaffolded front controller
already does on its first line. APP_PATH is used instead when it is defined.
The hit carries a CSP. Reading the file gets the csp block in the same breath,
so this path is no exception — see
a cache hit and the CSP nonce. If there is no
app.php to read, no policy is sent rather than a guessed one: the framework default
is default-src 'none', and sending that to an application that needed hosts in its
csp block would break the page it was meant to protect.
The static-file writer¶
Pages are also written as real files — index.html and index.html.gz — so a
rewrite rule can serve them without PHP starting at all. This is what WP Super
Cache calls mod_rewrite mode.
RewriteCond %{REQUEST_METHOD} ^(GET|HEAD)$
RewriteCond %{QUERY_STRING} ^$
RewriteCond %{HTTP_COOKIE} !(auth|remember|logged) [NC]
RewriteCond %{DOCUMENT_ROOT}/cache/%{HTTP_HOST}%{REQUEST_URI}/index.html -f
RewriteRule ^(.*)$ /cache/%{HTTP_HOST}/$1/index.html [L]
Measure before enabling it. The gain scales with the weight of the bootstrap
being skipped, not with the web server. An application already using
serveEarly() saves a millisecond or two; one that boots fully before consulting
the cache saves tens. One consuming application measured 5.8 ms for its PHP hit
path against 4.5 ms static — real, and much smaller than the advertising for this
technique suggests.
Four things to know:
- URLs with a query string are never written as files. A rewrite rule cannot
apply
ignoreQuery, so it would serve the clean page for?page=2. Those requests stay on the PHP path, where normalisation still happens. - Files are written to a temporary name and renamed, because a half-written page served to a visitor is worse than a slow one.
- Purges remove the static twin as well. They must — otherwise the rewrite keeps serving the file the purge reported having removed.
- Your rewrite conditions are now part of the bypass rules, and they are not checked against the PHP config. If they disagree, the file wins.
What a hit tells the browser¶
A hit routinely goes out carrying this:
x-pramnos-cache: HIT
pragma: no-cache
expires: Thu, 19 Nov 1981 08:52:00 GMT
cache-control: no-store, no-cache, must-revalidate
Nothing here chose those. They are PHP's, from session.cache_limiter — which
defaults to nocache and fires on session_start(). A front controller calls
$app->init() before the pipeline runs, so they are queued before the cache is asked
anything. And with 'session' => 'lazy' an
anonymous visitor starts no session and gets none of them, so which headers a hit
carries currently depends on whether a session happened to start.
It is not a problem after login — it is what prevents one. A hit is a shared
copy, and no-store is the right thing to tell a browser about one: the browser will
not keep the anonymous page and hand it back once the visitor has signed in. The
dangerous shape is the reverse. A hit carrying Cache-Control: public, max-age=3600
lets the browser — and any CDN in front — keep the anonymous page for an hour and serve
it to a signed-in user, and nothing in this framework can reach into either to purge it.
So the default is to leave them alone. What you lose is the second cache layer: a page you have already decided is shareable still costs every visitor a full round trip.
Set it and a hit carries that instead, with the leftover Pragma and Expires
removed — they have to go together, because Cache-Control: public alongside a queued
Pragma: no-cache is worse than either alone: every HTTP/1.0 intermediary believes the
Pragma.
Before setting it, be sure of two things, because a CDN is not purgeable from here:
- the page is identical for every visitor who can reach it, signed in or not — the bypass rules protect the server-side cache, not somebody's browser;
max-ageis short enough that you can live with the staleness, becausePageCache::purgeUrl()clears this store and cannot clear theirs.
Configuration reference¶
| Key | Default | |
|---|---|---|
enabled |
false |
|
store |
'file' |
file, redis, memcached, memcache, array. Unknown names fall back to file rather than throwing |
prefix |
'pagecache:' |
key namespace within the store |
ttl |
3600 |
|
ttlRules |
[] |
pattern => seconds, first match wins |
botTtl |
null |
overrides ttl for crawlers |
methods |
['GET','HEAD'] |
|
statuses |
[200] |
|
hosts |
[] |
empty means all |
onlyPaths |
[] |
empty means all |
bypassPaths |
[] |
globs or delimited regexes |
bypassQuery |
[] |
name => true or name => [values] |
bypassCookies |
['#^(auth\|remember\|logged)#i', '#^' . session_name() . '$#i'] |
the session cookie is on the list by default. An array-style cookie is matched as name[key], and the framework's own (Request::cookieset(), kept under a prefix by an encoded name) by the name it was given — so the remember-me logged bypasses |
bypassHeaders |
['Authorization'] |
|
ignoreQuery |
utm_*, fbclid, gclid, … |
dropped before keying |
varyQuery |
null |
whitelist; overrides ignoreQuery |
varyBy |
[] |
name => callable |
privateMarkers |
[] |
body substrings that prevent storing |
skipWhileDebugging |
true |
refuse to store while the debug toolbar is collecting |
| (not configurable) | — | a body containing this response's CSP nonce is never stored |
staleWhileRevalidate |
30 |
seconds past expiry |
lockTtl |
30 |
0 disables locking |
gzip |
true |
|
compressAtRest |
false |
keep only the gzipped body; unzipped for a client without gzip |
redis |
null |
['host', 'port', 'database', 'password']; unset, the cache settings', then the shared connection |
etag |
true |
|
cacheControl |
null |
what a hit — and the miss that was just stored — tells the browser; null leaves PHP's session limiter alone. 'private, no-cache' gives the back/forward cache and a 304 on each revalidation, with no shared copy |
debugHeader |
true |
X-Pramnos-Cache and Age |
debugDetail |
false |
adds the key, TTL and expiry |
cdnCacheControl |
false |
true sends CDN-Cache-Control: max-age=<what is left of the entry's TTL> on a HIT only; a string names another header (Cloudflare-CDN-Cache-Control). For an edge that caches HTML — and that must bypass on the login cookie first |
harmlessCookies |
['lastseen', 'visitorid'] |
cookies a response may queue with setcookie() and still be stored — the framework's visitor tracking. Any other queued cookie, the session's included, means the page answered one visitor: it is not stored |
pageSignal |
false |
Pramnos-Page: public; max-age=<ttl> on a HIT and on the miss just stored, Pramnos-Page: private on a page bypassed at runtime (PageCache::bypass()) or carrying a private marker, nothing otherwise — an allow-list for a service worker that keeps pages: only what this cache shares may be kept anywhere else |
writer |
'cache' |
or 'static' |
staticRoot |
'' |
empty means ROOT . '/www/cache' |
headerWhitelist |
content-type, content-language, link, vary |
the only response headers replayed |
headerWhitelist is a whitelist rather than a blacklist because the header that
must not be replayed to another visitor is always the one nobody thought of.
When a page is not being cached¶
In order, and each is one line:
Start here: is the application setting a cookie on every response? This is the first thing anyone hits, and it takes one command:
Any Set-Cookie at all means nothing will ever be stored. PHPSESSID is the
usual culprit and it is not something the application wrote: the framework starts
a session in Application::init() on every request unless told otherwise, and
without turning that off the page cache and the session are mutually exclusive as
shipped.
// app/app.php
'session' => 'lazy', // no session for a visitor who has none
'session_tracking' => false, // no tracking cookies either
Both halves hold whether or not the application lists SessionTrackingMiddleware in
middleware — worth knowing, because the two symptoms are identical: Set-Cookie on
every response, nothing stored, and no error to read.
See declining the automatic session for what lazy mode does and does not change, and declining session tracking for the spellings the second key accepts.
Then, in order, and each is one line:
$cache->whyBypassed($request)names the rule —cookie:authtoken,bypassPaths,method:POST,disabled, orruntime:<reason>when something calledPageCache::bypass(). If it returnsnull, the request is cacheable and the problem is on the store side.- Check the response for
Set-Cookie; anything that touches the session adds one. - Is the debug toolbar on? Nothing is stored while it collects — see
skipWhileDebuggingabove. - Check the status is on
statuses— a redirect is not cached by default. - Does the page have an inline
<script nonce="…">or<style nonce="…">? Then it is never stored, on purpose. One command tells you which:
Everything it lists is the application's own — the framework does not nonce data
blocks or its own no-js flip. See which of your inline scripts actually
needs a nonce.
6. Is the front controller returning a Response, or echoing render()'s string?
A string is passed through unstored — see
the pipeline has to return a Response.
7. X-Pramnos-Cache absent on a request you expected to hit means the lookup did
not find an entry. Turn on debugDetail and compare X-Pramnos-Cache-Key
across the two requests you expected to share a page — that is the answer
almost every time.
See also¶
- Cache Guide — the storage layer, adapters and categories
- Routing Guide — middleware registration
- Test Suite Performance — keeping invalidation off the hot path in tests