Seven things a PHP server can do when it stops throwing everything away
Askr 0.3.0 — a response cache with a kill switch, a Reverb you didn't install, workers that breed under load, and a test runner that boots once. A builder's tour.
A few weeks ago I wrote about the thesis behind Askr: what if the app just stayed booted? We embedded PHP inside a Rust server, booted Laravel once, and served it from a memory-safe binary at ~9× the FPM model. That was the foundation.
This release is what happens when you sit with that foundation for a while and start noticing — almost embarrassingly — that a whole pile of things the PHP world normally bolts on from the outside are already inside the building. There's a shared-memory substrate all the worker processes see. There's a copy-on-write template that respawns a warm app in milliseconds. And Askr owns the entire request lifecycle, start to finish.
So this release is seven features that didn't require new infrastructure. They just fell out of the architecture. Let me walk you through them the way I found them.
1. A response cache with an instant kill switch
Here's the one I'm most fond of. Most Laravel pages that anonymous visitors see are the same for everyone — a blog index, a product page, a marketing route. FPM re-renders them on every hit. The usual fix is Varnish or a Redis cache layer: more infrastructure, more moving parts, and the eternal question "how do I bust the cache when a post changes?"
Askr already has a shared-memory table every worker can see. So the cache lives in the server. The app opts a response in with a header:
// in a controller, only on pages you know are safe to cache
return response($html)->header('Askr-Cache', '60, tags=posts,homepage');
The next anonymous GET for that URL is served straight from Rust — PHP is never touched. It runs at static-file speed. You can watch it happen:
$ curl -sD- localhost/posts | grep -i x-askr-cache
x-askr-cache: MISS # first hit: PHP ran, response stored
$ curl -sD- localhost/posts | grep -i x-askr-cache
x-askr-cache: HIT # second hit: never reached PHP
But the part that makes it feel different is invalidation. Every cached entry remembers its tags. When your data changes, you bump the tag:
class Post extends Model {
protected static function booted() {
static::saved(fn () => askr_cache_forget_tag('posts'));
}
}
That call increments a generation counter in the shared tag table. Every cached response carrying posts — across every worker process — goes stale at that instant. O(1). No scan, no cache-server round-trip, no coordination. It's the Varnish effect, driven by your app, with nothing to install.
(Two safety rails, because caching auth'd content is how you leak someone's session to the world: only cookie-less GET/HEAD requests are cacheable, and Set-Cookie is stripped on store. The app opts in; Askr keeps it honest.)
2. …and it never stampedes
Once the cache existed, the classic failure mode was one line away: a cold cache, and 200 identical requests arrive at once. Everyone misses, everyone runs PHP, your database melts.
Because all the workers share memory, the fix is easy: the first request to miss becomes the leader and runs PHP; the other 199 wait for it to fill the cache, then get served from it. I tested it with 20 simultaneous requests to a 300 ms endpoint on a cold cache:
20 concurrent cold-cache requests → 346 ms total (not ~6 s)
PHP ran exactly once. coalesced: 19
Twenty requests, one execution. Stampede gone. You didn't configure anything — it's automatic whenever the response cache is on.
3. The Reverb you didn't install
Askr already had a broadcast ring feeding Server-Sent Events. But the Laravel ecosystem — Echo, Livewire — speaks the Pusher protocol. So this release teaches Askr to speak it too: a WebSocket endpoint at /app/{key} and the HTTP trigger POST /apps/{id}/events that Laravel's broadcaster calls server-side.
Point Echo at Askr, change nothing else, and it works. Here's the whole loop, verified with a real WebSocket client:
→ connect ws://localhost/app/localkey
← {"event":"pusher:connection_established", ...}
→ {"event":"pusher:subscribe","data":{"channel":"orders"}}
← {"event":"pusher_internal:subscription_succeeded","channel":"orders",...}
meanwhile, server-side, Laravel broadcasts an event:
$ curl -XPOST localhost/apps/1/events -d '{"name":"OrderCreated","channel":"orders","data":"..."}'
← {"event":"OrderCreated","channel":"orders","data":"{"id":42}"}
A trigger in any worker reaches subscribers in all of them, because it rides the same shared ring. That's the whole real-time story — no Reverb, no external broker, one binary. (Public channels are complete; private/presence auth-signature verification is the next step, and I'd rather ship it honestly than pretend.)
4. Work that happens after "send"
You know the feeling: the response is ready, but you still have to fire off a welcome email, hit a webhook, write an audit log — and the user is sitting there watching a spinner while you do it.
Since Askr controls the whole lifecycle, it can hand the response to the client first, then do the rest:
askr_defer(function () use ($user) {
Mail::to($user)->send(new Welcome); // runs after the client already left
});
Rust flushes the reply; the worker runs your deferred closures before it accepts the next request. Octane calls this defer() but needs its own machinery — here it's just there. I proved it to myself the dumb, satisfying way: the client got its response in 18 ms, and the "slow work" file appeared 50 ms after that.
5. Workers that breed under load — and get harvested
This one has never been practical in PHP, and it bugged me for years. Autoscaling processes is the natural way to handle a traffic spike, but PHP boot is ~300 ms, so spinning up a worker mid-spike is like starting a car by pushing it uphill.
Except Askr has the CoW template: it boots the app once and forks warm workers in ~35 ms. Suddenly the arithmetic flips. So the template now watches a live queue-depth gauge in shared memory and sizes the pool itself:
askr serve --cow --worker-script … --workers-min 2 --workers-max 12
Under load it adds warm workers; when the rush passes it harvests them back down. Straight from the log of a test run:
cow autoscale up busy=10 alive=1 → desired=2
cow autoscale up busy=18 alive=2 → desired=3
cow autoscale up busy=23 alive=3 → desired=4
…
cow autoscale down (harvest) busy=0 → 3
cow autoscale down (harvest) busy=0 → 2
cow autoscale down (harvest) busy=0 → 1
No other PHP server can do this cheaply, and it's purely because respawn is warm.
6. Replaying production, instead of guessing
A 500 in production is the worst debugging loop: you have a stack trace, maybe, and then you spend an hour trying to reproduce the exact request. Askr sees every request in full, so it can just… keep the bad ones:
askr serve … --record-errors /var/lib/askr/errors
Every 5xx writes its whole CGI envelope — method, URI, the full $_SERVER, the raw body. Later:
$ askr replay /var/lib/askr/errors/1783232551-89972-0.json
↻ replaying … → POST
HTTP 500
X-Boom: yes
boom uri=/boom?debug=1 body=payload=42
Same method, same query, same headers, same body, against a fresh interpreter. Debugging goes from "try to reproduce" to "replay it." (It captures request bodies, so treat that directory as sensitive — but you already knew that.)
7. A test runner that boots once
Last one, and the most fun to build. paratest speeds up your suite by running test files in parallel processes — but every one of those processes pays the full cold boot. With a warm CoW template, you don't have to:
askr test --root /path/to/app --runner examples/askr-test.php tests/
Boot the app once; fork a fresh, warm process per test file. You get perfect isolation — no state bleeds between files, because each is its own process — and no cold boot per file. My little proof:
✓ IsolationTest.php # a global set in another file? not visible here.
✗ FailTest.php (exit 1)
✓ PassTest.php
3 file(s): 2 passed, 1 failed
Point --runner at the included PHPUnit shim and it drives Pest/PHPUnit per file. (Building it taught me a lovely little truth about the embed SAPI, too: exit(0) unwinds as a Zend bailout, so I was briefly reporting every clean exit as a failure until I learned to trust EG(exit_status) over the bailout code.)
The thread running through all of it
None of these are clever tricks. They're the same three ideas paying off over and over:
Shared memory across processes → the cache, its tag table, the coalescing lock, the broadcast ring, the autoscaler's gauge. No IPC, no broker.
Copy-on-write respawn in milliseconds → autoscaling becomes cheap, and so does the test runner.
Owning the whole request lifecycle → defer after send, record on 5xx, cache before PHP even wakes up.
The whole release is honest about its edges — private-channel auth, warm-autoload in the test runner, and the big one still ahead: the per-core io_uring I/O core and a real benchmark against FrankenPHP and FPM. That's where "the most efficient PHP server" stops being a claim and becomes a number.
But the shape of the thing is clear now. Boot once, share memory, respawn warm — and a startling amount of the infrastructure the PHP world usually assembles from separate boxes just… collapses into one binary you can curl down and run.
If you run Laravel, it's one command to try:
VER=v0.3.0; ARCH=$(uname -m)
curl -fsSLO https://github.com/kwhorne/askr/releases/download/$VER/askr-${VER#v}-linux-$ARCH.tar.gz
tar xzf askr-${VER#v}-linux-$ARCH.tar.gz && cd askr-${VER#v}-linux-$ARCH
./askr-run.sh serve --root /var/www/app/public
--worker-script examples/laravel-worker.php
--response-cache 512 --pusher --workers "$(nproc)"
Cache a page, forget a tag, watch a worker get born. Then tell me what breaks. 🌳
Askr is MIT-licensed, built by Knut W. Horne / Wirelabs AS. The name is the ash tree the gods carved the first human from — the thing that came alive and faced the world. Its dev-tool sibling is Grove.