Askr 1.4 — the cache that proves it's safe to cache
Full-page caching is rare in PHP because nobody trusts it. Askr 1.4 adds a cache oracle that reports what's actually safe to cache, and automatic dependency tagging so invalidation stops being a maintenance job.
Why full-page caching is rare in PHP, and what it takes to make it boring.
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:
"I don't know how much it would actually save."
"I'm scared we'd serve the wrong page to the wrong person."
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.
That's this release. Two features, one idea: find out what's safe to cache, then cache it correctly without maintaining anything.
Part one: stop guessing
Askr sees every request. It also sees every response body. So it can simulate a cache without caching anything at all:
askr serve --traffic-log /tmp/traffic.jsonl # leave it for an hour
askr cache-report /tmp/traffic.jsonl
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
The hit-rate column is the boring part. Anyone can estimate that from an access log.
Look at /dashboard. 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.
The oracle catches it, because it asks a question a log can't answer:
Did the same URL ever return different bytes inside the TTL window?
Fifteen times, it did. So /dashboard is marked unsafe and — this matters — left out of the config the report invites you to paste. Same for /login, which sets a cookie.
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.
A note on the design. Collection and analysis are deliberately separate. --traffic-log 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 cache-report can be as thorough as it likes, offline. URLs are grouped into patterns (/products/1421 → /products/) so the output is the handful of rules a human would actually write.
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 PHP time 0.0 s 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.
Part two: stop maintaining tag lists
Now you know what's worth caching. The second fear remains: invalidation.
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.
So don't write them:
Route::get('/products/{product}', ProductController::class)
->middleware('askr.cache:300');
That's the whole change. The middleware listens to Eloquent's retrieved event, notes every model the response actually read, and tags the cached page with them. Then $product->save() clears exactly the pages that showed that product — across every worker, immediately.
Here's a real run against a Laravel 12 app:
/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
No tag list exists anywhere in that app.
Precision while it's cheap, safety when it isn't
A cached entry holds up to eight tags, so the collector degrades on purpose:
The response read It's tagged A change clears a few models per instance (products:42) only pages showing that product many models per class (products) every page that listed products more classes than fit nothing, and it isn't cached —
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 create() clears the class tag too:
/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
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.
And it refuses when the page isn't shared
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.
The bug the feature found
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:
for (i, tag) in tags.iter().take(MAX_TAGS).enumerate() {
Nine tags in, eight tags stored, one silently dropped. And askr_cache_forget_tag() 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.
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 askr_cache_tag_overflow_total. 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.
Fail safe, and fail visibly.
Where the verification came from
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: composer --no-scripts had skipped package discovery so the service provider never loaded, and a probe.php returned 404 because Askr now correctly routes .php through the front controller — my own security fix from 1.0.1, working exactly as designed, breaking my own probe.
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.
The reminder that found something worse
Late in the release, Knut said: "vi må ikke glemme laravel pakken" — don't forget the Laravel package.
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.
Then I checked whether the release had actually reached Packagist.
split repo, last commit: 2026-07-17
only tag: v0.9.3
src/Http/: does not exist
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:
no ASKR_LARAVEL_SPLIT_TOKEN secret set — skipping split
The token guard was exit 0. 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 composer require kwhorne/askr-laravel today gets the July state: no middleware, no dependency collector.
I can't add the secret — only the maintainer can. But I could fix the thing that made it invisible:
missing token on a version tag → error and fail, because a release that doesn't publish is a broken release;
missing token on an ordinary push → a warning annotation, since a missed incremental sync is recoverable;
and a final step that asserts the tag really exists in the split repo, so "success" means published, not "the commands printed something".
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.
The freeze held, again
Five releases into 1.x and nothing has broken. 1.4.0 adds a subcommand (cache-report, duly recorded in STABILITY.md), one [server] key, and a middleware in the Laravel package. Every addition is opt-in.
docker pull ghcr.io/kwhorne/askr:1.4.0 # or :latest
composer update kwhorne/askr-laravel # once the publish token is back
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. 🌳