<p><em>Why Edge-Side Includes, PURGE/BAN and cache rules, and how they actually work.</em></p><p>There's a moment every Laravel developer has had. You profile a slow page, find that it's 180ms of database work rendering the same HTML for every visitor, and think: I'll just cache it. Then you remember the little cart widget in the top-right corner that says "3 items · 749 kr", and the whole plan collapses. That widget is different for every single visitor. One tiny bit of HTML, and a page that could have been served in a hundred microseconds goes back to costing 180 milliseconds — for everyone, forever.</p><p>That's the problem Askr 1.1 is about.</p><h2>The honest shape of the problem</h2><p>Caching a whole page is easy. Askr has had that since 0.3: mark a response cacheable, and it's served straight from shared memory at static-file speed without PHP ever waking up.</p><pre><code class="language-php">header('Askr-Cache: 3600, tags=posts');   // and askr_cache_forget_tag('posts') to bust it
</code></pre><p>The trouble is that real pages are almost static. There's a cart. A "logged in as Knut". A stock counter. A CSRF token. And the moment one of those exists, page caching is off the table — so most teams end up caching nothing, or reaching for Varnish and accepting a second network hop plus a second config language.</p><p>Varnish solved this properly two decades ago with Edge-Side Includes. Askr 1.1 brings that in-process.</p><h2>ESI: cache the page with holes</h2><p>The trick is deciding when the holes get filled. Askr caches the page with the tags still in it, and assembles on the way out:</p><pre><code class="language-php">// The shell: cached for an hour, tags and all
header('Askr-Cache: 3600');
header('Askr-ESI: on');          // opt in — Askr only scans bodies that ask
?&gt;
&lt;html&gt;&lt;body&gt;
  &lt;esi:include src="/_esi/header"/&gt;   &lt;!-- its own 24h TTL --&gt;
  &lt;main&gt;Article body, all 180ms of it…&lt;/main&gt;
  &lt;esi:include src="/_esi/cart"/&gt;     &lt;!-- no Askr-Cache header ⇒ per request --&gt;
  &lt;esi:remove&gt;&lt;p&gt;Only caches that don't speak ESI show this&lt;/p&gt;&lt;/esi:remove&gt;
&lt;/body&gt;&lt;/html&gt;
</code></pre><p>Each fragment is an ordinary request through your front controller. It routes like any other URL, hits your middleware, and carries its own <code>Askr-Cache</code> header — which means its own TTL, its own tags, and its own PURGE. In Laravel:</p><pre><code class="language-php">Route::get('/_esi/cart', function () {
    // no cache header: rendered fresh every time, and it's tiny
    return view('partials.cart', ['cart' =&gt; Cart::current()]);
});

Route::get('/_esi/header', function () {
    return response(view('partials.header'))-&gt;header('Askr-Cache', '86400, tags=nav');
});
</code></pre><p>Here's the actual output of three consecutive requests to a test page, straight from my terminal:</p><pre><code class="language-text">&lt;html&gt;SHELL=42d2 [HEADER cached=07fd][CART live=70afc0]&lt;/html&gt;
&lt;html&gt;SHELL=42d2 [HEADER cached=07fd][CART live=785e09]&lt;/html&gt;
&lt;html&gt;SHELL=42d2 [HEADER cached=07fd][CART live=db8c94]&lt;/html&gt;
</code></pre><p>The shell is byte-identical — PHP never ran for it. The header is byte-identical — cached for a day. The cart changes every single time. And this is with <code>X-Askr-Cache: HIT</code> on the response: the expensive 180ms page is a cache hit while the cart is still live.</p><p>Fragments nest, too. A cached fragment can contain its own includes, each with an independent TTL. I resolve that with up to three passes rather than recursion — which, pleasingly, also means no boxed futures on the response path.</p><p>What happens when a fragment breaks? It leaves the hole empty and logs a warning. A broken cart widget must never take down the article. And <code>src</code> must be a same-origin absolute path — I tested an include pointing at <code>http://169.254.169.254/latest/meta-data/</code>, the cloud metadata endpoint, and it was refused. An ESI tag must never become an outbound fetch, or a template injection turns your server into an SSRF proxy from inside the trust boundary.</p><h2>PURGE and BAN: invalidating by URL</h2><p>Tag invalidation answers "this content changed". Sometimes you need "this URL changed" — from a deploy script, a CMS webhook, or by hand at 2am:</p><pre><code class="language-bash"># Drop every cached variant of one URL (all encodings, all device classes, GET &amp; HEAD)
curl -X PURGE https://example.com/posts/123
#=&gt; {"purged":3}

# Drop everything under a path, by glob
curl -X BAN -H 'X-Ban-Url: /category/tech/*' https://example.com/
#=&gt; {"banned":12}
</code></pre><p>They answer with a count, deliberately. A purge that matched nothing is the kind of thing you want to notice immediately, not discover next week.</p><p>Two design notes I'd defend in a code review:</p><p><strong>PURGE </strong><code>/posts/1</code><strong> does not touch </strong><code>/posts/12</code><strong>.</strong> Matching stops at a path-component boundary. That's the sort of bug that would be quietly wrong for months.</p><p><strong>BAN is an eager scan, not a rule list.</strong> The obvious implementation keeps ban patterns in shared memory and checks every cache lookup against them — which puts glob matching under a spinlock on the hot path, forever, for everyone. Instead a BAN walks the slots once and tombstones what matches. Invalidation costs one pass; the request path costs nothing. Entries stored after the ban are untouched, which is correct: they were rendered from current data.</p><p>Both are gated on <code>ASKR_ADMIN_TOKEN</code>, and without a token they're accepted from loopback only. An open purge endpoint is a cache-wiping DoS. I got a nice confirmation of this while verifying the published Docker image:</p><pre><code class="language-text">$ curl -X PURGE http://127.0.0.1:8086/
askr: set ASKR_ADMIN_TOKEN to allow PURGE/BAN from a non-loopback address
</code></pre><p>The container's IP isn't loopback, so it refused. The default behaved correctly in a situation I hadn't specifically planned to test.</p><h2>Cache rules: policy without touching the app</h2><p>Everything above assumes you can edit the application. Sometimes you can't — a legacy app, a vendor package, a site you inherited with no tests. So cache policy can now live in <code>askr.toml</code>:</p><pre><code class="language-toml">[cache]
response_slots = 512

# Never cache the admin area, whatever the app says
[[cache.rule]]
path = "/admin/*"
action = "pass"

# Cache these for a day even though the app never opted in —
# and even for visitors carrying cookies
[[cache.rule]]
path = "/static/*"
ttl = 86400
force = true

# Everything else: short TTL with a stale-if-error safety net
[[cache.rule]]
path = "/*"
ttl = 300
swr = 30
stale_if_error = 3600
</code></pre><p>First match wins. I verified this against an app that sets no cache header at all: <code>/page</code> became a HIT with an identical body, <code>/admin/*</code> stayed uncached and reported <code>X-Askr-Cache: PASS</code>, and <code>/static/*</code> cached even with a <code>laravel_session</code> cookie present. The control case — <code>/page</code> with a cookie and no <code>force</code> — correctly stayed uncached.</p><p>A detail I'm happy with: a rule's <code>ttl</code> overrides the app's header, but keeps the app's tags. So a rule-cached page can still be busted by <code>askr_cache_forget_tag()</code>. Rules and a partly-cooperative app compose instead of fighting.</p><p>And a warning I put in the docs in plain language:</p><blockquote><p><code>force</code> is the dangerous one. Exactly as in Varnish, if the path can render anything user-specific, one visitor's page gets served to everyone. <code>Set-Cookie</code> is still stripped on store, but that doesn't make a personalised body safe to share.</p></blockquote><h2>What I said no to</h2><p>The original plan for the rule engine had three phases: declarative TOML, then embedded Rhai scripting, then Wasm plugins. I built phase one and stopped.</p><p>Before writing any code I mapped what VCL is actually used for against what Askr already had:</p><pre><code class="language-text">VCL use                           Askr
--------------------------------  ------------------------------------------
host redirects                    [[redirect]] (0.9.12)
force https                       force_https (0.9.12)
hash_data / cache keys            strip_query_params, ignore_cookies (1.0.1)
ban / purge                       this release
stale-if-error / saint mode       1.0.1
ESI                               this release
return(pass)                      ← the actual gap
beresp.ttl without app changes    ← the actual gap
</code></pre><p>Six of eight were already config. The gap was two things, and both are now six lines of TOML. A scripting engine would put arbitrary code on the cache decision path, hand me a sandbox to secure, and freeze a scripting API under the 1.0 stability contract — to do what a table already does.</p><p>The docs now invite an issue with a concrete case that rules can't express. That's better evidence for a script engine than the idea of one.</p><h2>Two security findings, and how they were found</h2><p>While e2e-testing stale-if-error, I fired a curl at a <code>.php</code> file out of idle curiosity. It returned the source code. Static file serving had no extension filtering, so <code>GET /index.php</code> downloaded the file instead of running it — and with a document root pointed at an app root, <code>GET /.env</code> returned <code>APP_KEY</code> and the database password in plain text.</p><p>That went out immediately as 1.0.1. Then I ran a proper probe sweep of the same code path and found the same class one step over — <code>index.php.bak</code>, <code>config.php~</code>, <code>db.php.save</code> — which is fixed in 1.1.0.</p><p>Both are worth being blunt about: four separate code reviews had not found them. It took a stray curl against a real app. Reading code proves what it says; poking it proves what it does.</p><p>The sweep also cleared several things I'd rather know than assume: percent-encoded traversal, NUL tricks and trailing-dot paths were all already refused, and there is no directory listing. And one thing I deliberately did not "fix": Askr follows symlinks out of the document root, because <code>php artisan storage:link</code> creates exactly that, and nginx and Apache behave the same way. Blocking it would break uploads for most Laravel apps. That's documented instead of changed.</p><p>Along the way I also got fooled twice by my own test setup — once because <code>kill %1</code> didn't kill anything (each shell is a new shell), once because <code>cargo test</code> doesn't rebuild the binary, so I "proved" a fix didn't work using an 18-minute-old build. Both times the test lied, not the code. It's a good reminder of how easy it is to conclude the wrong thing with real evidence in front of you.</p><h2>The freeze held</h2><p>1.0 froze Askr's surface under SemVer. This release is the first real test of that promise: five user-visible features and a security fix, and not one breaking change. Every addition is a new default-off <code>[cache]</code> key or an opt-in response header. The new bits of HTTP surface — PURGE/BAN with <code>X-Ban-Url</code>, <code>Askr-ESI</code> and the tag syntax, <code>X-Askr-Cache: PASS</code> — are recorded in <code>STABILITY.md</code> so they're covered going forward.</p><p>1.1.0 is a drop-in for 1.0.x. That was the whole point of writing the contract down.</p><h2>Where this leaves things</h2><p>Askr now does, in one binary and with no extra hop, what people run Varnish for: full-page caching, ESI assembly, purge and ban, stale-if-error with saint mode, cache-key normalisation, and per-path policy — plus instant tag invalidation across all workers, which Varnish doesn't have, because Askr is inside your application rather than in front of it.</p><p>The honest limits are in the docs, including my favourite: because the cache key includes the negotiated encoding, an ESI shell is stored once per encoding class your clients negotiate. Identical bodies, a few extra slots, one render per class. Not correctness — just a little waste I'd rather write down than pretend away.</p><pre><code class="language-bash">docker pull ghcr.io/kwhorne/askr:1.1.0        # or :latest
composer require kwhorne/askr-laravel
</code></pre><p>Go cache that page with the cart widget on it. 🌳</p>