<p><em>Why full-page caching is rare in PHP, and what it takes to make it boring.</em></p><p>Ask a Laravel team why they don't cache whole pages and you'll rarely hear "it wouldn't help". You'll hear some version of two other things:</p><blockquote><p>"I don't know how much it would actually save."</p></blockquote><blockquote><p>"I'm scared we'd serve the wrong page to the wrong person."</p></blockquote><p>Both are trust problems, not performance problems. And both are answerable — if the server and the interpreter happen to live in the same process, which is the one thing Askr has that a proxy in front of PHP-FPM does not.</p><p>That's this release. Two features, one idea: find out what's safe to cache, then cache it correctly without maintaining anything.</p><h2>Part one: stop guessing</h2><p>Askr sees every request. It also sees every response body. So it can simulate a cache without caching anything at all:</p><pre><code class="language-bash">askr serve --traffic-log /tmp/traffic.jsonl    # leave it for an hour
askr cache-report /tmp/traffic.jsonl
</code></pre><pre><code class="language-text">samples        80
window         0.5 s
PHP time       18 ms total

pattern                        ttl    hit PHP saved  safety
----------------------------------------------------------------------------------
/products/*                    60s    94%    1.48 s/m  ✓ identical for every visitor
/dashboard                     60s    94%    0.11 s/m  ✗ unsafe: 15 responses differed for the same URL
/login                         60s    88%    0.06 s/m  ✗ unsafe: 8 responses set a cookie
/                              60s    88%    0.05 s/m  ✓ identical for every visitor

Safe rules alone would have removed 73% of the PHP time above (13 ms of 18 ms).

Suggested askr.toml:

  [[cache.rule]]
  path = "/products/*"
  ttl = 60
</code></pre><p>The hit-rate column is the boring part. Anyone can estimate that from an access log.</p><p>Look at <code>/dashboard</code>. 94 % hit rate. By every conventional measure it's the second-best caching opportunity on the site. A hit-rate report would put it near the top of your to-do list, you'd add the rule, and then one visitor's dashboard would be served to everyone until the TTL expired.</p><p>The oracle catches it, because it asks a question a log can't answer:</p><blockquote><p>Did the same URL ever return different bytes inside the TTL window?</p></blockquote><p>Fifteen times, it did. So <code>/dashboard</code> is marked unsafe and — this matters — left out of the config the report invites you to paste. Same for <code>/login</code>, which sets a cookie.</p><p>That check needs the cache key and the response bytes together. Askr has both, in the same function, for free. Varnish sitting in front of your app has the bytes but not your session semantics; your app has the semantics but never sees the cache. This is what "in-process" buys you that isn't a benchmark number.</p><p>A note on the design. Collection and analysis are deliberately separate. <code>--traffic-log</code> writes one JSON line per request that ran PHP — so the log describes work still being done, not what the cache already absorbed — at the cost of a single write on the hot path. Then <code>cache-report</code> can be as thorough as it likes, offline. URLs are grouped into patterns (<code>/products/1421</code> → <code>/products/*</code>) so the output is the handful of rules a human would actually write.</p><p>And it tells you what it doesn't know. My test sample above covers half a second, so the report says so out loud rather than presenting 2.10 CPU-s per minute as if it meant something. The first version did present it that way — and printed <code>PHP time 0.0 s</code> for a real 18 ms, which reads as "nothing". A tool whose whole job is giving advice doesn't get to overstate its own evidence.</p><h2>Part two: stop maintaining tag lists</h2><p>Now you know what's worth caching. The second fear remains: invalidation.</p><p>Askr has had instant tag invalidation across all workers since 0.3. The catch was always that somebody has to write the tags — and keep them right, forever, as the page grows a new relationship nobody remembers to tag. One miss serves stale content, trust evaporates, caching gets switched off.</p><p>So don't write them:</p><pre><code class="language-php">Route::get('/products/{product}', ProductController::class)
    -&gt;middleware('askr.cache:300');
</code></pre><p>That's the whole change. The middleware listens to Eloquent's <code>retrieved</code> event, notes every model the response actually read, and tags the cached page with them. Then <code>$product-&gt;save()</code> clears exactly the pages that showed that product — across every worker, immediately.</p><p>Here's a real run against a Laravel 12 app:</p><pre><code class="language-text">/widget/1 first:    widget 1 = alpha @ 6e7af4
/widget/1 second:   widget 1 = alpha @ 6e7af4   [HIT]

# rename widget 1
/widget/1:          widget 1 = renamed-233e @ 224cc5   ← invalidated
/widget/2:          unchanged                          ← precise
</code></pre><p>No tag list exists anywhere in that app.</p><h3>Precision while it's cheap, safety when it isn't</h3><p>A cached entry holds up to eight tags, so the collector degrades on purpose:</p><p>The response read It's tagged A change clears a few models per instance (<code>products:42</code>) only pages showing that product many models per class (<code>products</code>) every page that listed products more classes than fit nothing, and it isn't cached —</p><p>The middle row is what makes a listing page work at all, and it has a subtlety worth pointing at. A brand-new product has no page of its own to invalidate — but the listing that should now include it does. So <code>create()</code> clears the class tag too:</p><pre><code class="language-text">/widgets (13 models → class tag):  all: alpha,beta,gamma,w0…w9   [HIT]
# Widget::create()
/widgets:  all: alpha,beta,gamma,w0…w9,new-9faa   ← listing died
/widget/1: still cached                            ← per-instance page survived
</code></pre><p>The bottom row is the honest one: if a page's dependencies can't be expressed, it isn't cached. A page you can't invalidate is worse than a page you didn't cache.</p><h3>And it refuses when the page isn't shared</h3><p>The middleware only marks a response cacheable when it can tell it belongs to everybody: a GET returning 200, nobody authenticated, no cookie set, and a session holding nothing beyond its own bookkeeping. A route that writes to the session and one that sets a cookie were both correctly refused in testing. A missed cache hit costs milliseconds; a wrongly shared page costs trust.</p><h2>The bug the feature found</h2><p>Automatic tagging needs to run into the eight-tag limit constantly — a listing page reads dozens of models. So I went to look at what happens at the boundary, and found this in the store path:</p><pre><code class="language-rust">for (i, tag) in tags.iter().take(MAX_TAGS).enumerate() {
</code></pre><p>Nine tags in, eight tags stored, one silently dropped. And <code>askr_cache_forget_tag()</code> can never reach a tag that isn't there — so that page would sit stale until its TTL expired, with nothing in any log to suggest anything was wrong.</p><p>That's the worst failure mode a cache has, and it was reachable by anyone hand-writing a long tag list today. Askr now refuses to cache such a response, warns once, and counts <code>askr_cache_tag_overflow_total</code>. It's in the upgrade guide as a behaviour change, because someone may see a page stop being cached — and the honest answer is that the page was already broken, it just failed quietly.</p><p>Fail safe, and fail visibly.</p><h2>Where the verification came from</h2><p>I tested this against a real Laravel 13 app rather than reading it carefully, and two runs failed first for reasons that had nothing to do with the code: <code>composer --no-scripts</code> had skipped package discovery so the service provider never loaded, and a <code>probe.php</code> returned 404 because Askr now correctly routes <code>.php</code> through the front controller — my own security fix from 1.0.1, working exactly as designed, breaking my own probe.</p><p>Both times my instinct was "the feature is broken". Both times the harness was lying. That keeps happening, and it's why 1.3 put end-to-end tests in CI.</p><h2>The reminder that found something worse</h2><p>Late in the release, Knut said: "vi må ikke glemme laravel pakken" — don't forget the Laravel package.</p><p>Fair enough. I went to check whether anything needed updating, and found the package used eight Illuminate namespaces while declaring two — under-declaration older than my changes. Fixed, verified with a real install.</p><p>Then I checked whether the release had actually reached Packagist.</p><pre><code class="language-text">split repo, last commit:  2026-07-17
only tag:                 v0.9.3
src/Http/:                does not exist
</code></pre><p>The Laravel package had not been published since 17 July. Every release since 1.0.1 showed a green check for the publish workflow. Here's the line, buried in a log nobody reads:</p><pre><code class="language-text">no ASKR_LARAVEL_SPLIT_TOKEN secret set — skipping split
</code></pre><p>The token guard was <code>exit 0</code>. Missing credential, print a note, finish successfully. So the workflow was cheerfully reporting success for doing nothing — including on version tags, where publishing is the entire job. Anyone running <code>composer require kwhorne/askr-laravel</code> today gets the July state: no middleware, no dependency collector.</p><p>I can't add the secret — only the maintainer can. But I could fix the thing that made it invisible:</p><ul><li><p>missing token on a version tag → error and fail, because a release that doesn't publish is a broken release;</p></li><li><p>missing token on an ordinary push → a warning annotation, since a missed incremental sync is recoverable;</p></li><li><p>and a final step that asserts the tag really exists in the split repo, so "success" means published, not "the commands printed something".</p></li></ul><p>It's the same lesson as the flaky test in 1.3, and as the tag truncation above, arriving from a third direction in one week: a signal that can mean "nothing happened" is worse than no signal at all. Green has to be expensive to earn, or you learn to stop reading it.</p><h2>The freeze held, again</h2><p>Five releases into 1.x and nothing has broken. 1.4.0 adds a subcommand (<code>cache-report</code>, duly recorded in <code>STABILITY.md</code>), one <code>[server]</code> key, and a middleware in the Laravel package. Every addition is opt-in.</p><pre><code class="language-bash">docker pull ghcr.io/kwhorne/askr:1.4.0        # or :latest
composer update kwhorne/askr-laravel          # once the publish token is back
</code></pre><p>Run the oracle for an hour. Add the rules it calls safe. Let the middleware keep them honest. Then go and think about something more interesting than cache invalidation. 🌳</p>