Askr · · 6 min read

Askr: the real server for Laravel & PHP — a builder's story

Why I embedded PHP inside a Rust web server, how it works, and what I learned making Laravel boot once and serve forever.

Askr: the real server for Laravel & PHP — a builder's story

It started, like most of my side projects do, with a small itch that wouldn't go away.

Every PHP request throws away the world and rebuilds it. Autoloader, service providers, the container, config, middleware — all of it, on every single request. FPM has done this faithfully for a decade, and it works. But somewhere in the back of my head a voice kept asking: what if the app just… stayed booted?

FrankenPHP and Laravel Octane already answered part of that. But FrankenPHP is built on Caddy and inherits Go's garbage collector, and Octane needs an external runtime. I wanted something else: one memory-safe binary, PHP running in-process, and the app booted exactly once. So one weekend I opened an empty folder and started a spike.

This is the story of that spike growing into Askr.

Why bother?

Four costs hide inside "serving PHP," and I wanted to drive all of them toward zero at the same time:

  1. Bootstrap per request — the ~110 ms of framework boot Laravel pays every time.

  2. The IPC hop — nginx → FastCGI → FPM → back again.

  3. GC pauses — the tax you pay with Go-based servers.

  4. Isolation that costs — you either pay in memory (prefork) or in state bleed (worker mode).

The insight that made it worth doing: the HTTP throughput isn't the bottleneck — Rust laughs at that. The bottleneck is that PHP boots too much, too often. Fix that, keep it memory-safe, and put the whole thing in one binary, and you've got something genuinely new for the Laravel world.

How it works, in three ideas

1. PHP lives inside the Rust process

Askr links PHP's embed SAPI (libphp) directly. No FastCGI, no FPM pool — the Zend engine runs in-process. The first time I saw this work was a genuine "oh, it's real" moment:

$ cargo run -p askr-php --example hello
embedded PHP version: 8.4.11
hello from PHP 8.4.11
{ "stack": "TALL", "server": "askr", "n": 55 }

All the unsafe in the whole project is confined to that thin FFI boundary. Everything else — the accept loop, TLS, the supervisor — is safe Rust. PHP is the single unsafe frontier, and it's the only C we own (a ~200-line shim).

2. It scales by processes, not threads

Here's a subtlety that shaped the entire architecture. Askr uses a non-ZTS build of PHP — no thread-safety tax. But non-ZTS means one interpreter per process; you literally cannot run two in one process.

So Askr doesn't spin up threads. It runs one worker process per CPU core, each with its own interpreter, all accepting on a shared socket. That is the share-nothing, thread-per-core philosophy — realised with processes. No locks on the hot path, nothing shared that shouldn't be.

3. Worker mode: boot once, serve many

This is where it gets fun. Instead of running index.php from scratch every time, a tiny worker script boots the app once and then loops:

$app = require $base . '/bootstrap/app.php';
$kernel = $app->make(Kernel::class);

while (askr_handle_request(function (array $request) use ($kernel) { $req = Illuminate\Http\Request::create( $request['uri'], $request['method'], [], $cookies, [], $server, $request['body'] ); $response = $kernel->handle($req); http_response_code($response->getStatusCode()); foreach ($response->headers->all() as $n => $vs) foreach ($vs as $v) header("$n: $v", false); echo $response->getContent(); $kernel->terminate($req, $response); })) {}

askr_handle_request blocks until Rust hands it a request, runs your handler against the already-booted app, and ships the response back — all in-process, no IPC.

The numbers, on a real Laravel 12 + Livewire app:

per-request (the FPM model) worker mode (boot once) latency / request ~110 ms ~9 ms throughput (8 workers) 37 req/s 347 req/s

The very first request costs 303 ms (cold boot). The second? 9.9 ms. The third, 8.9 ms. That gap — that's the whole thesis, sitting right there in the logs.

The fun parts (a.k.a. what nobody tells you)

Building this taught me things I'll never un-know:

  • STDERR doesn't exist in the embed SAPI. It's a CLI-only constant. My first test scripts kept fataling on fwrite(STDERR, ...) and I blamed everything except the one thing that was wrong.

  • The extension matrix is real. Booting Laravel surfaced exactly what it needs, one fatal at a time: mb_split() → oniguruma. openssl_cipher_iv_length() → OpenSSL. Class "DOMDocument" not found → libxml2. Each one built from source, statically. The macOS SDK's libxml2 was even too old for PHP 8.4's ext/dom.

  • Laravel boots its providers on the first request, not when you build the kernel. That one cost me an evening on the state-bleed detector.

  • Forking a booted interpreter is safe if you fork while single-threaded. The CoW template boots PHP on the main thread and forks before tokio starts — no multi-threaded-fork hazard. It felt like cheating when it worked.

What you actually get

Askr grew a personality. A few of my favourite bits:

"Is my app worker-safe?" — the #1 fear with the Octane model is state leaking between requests. So Askr will just tell you:

$ askr serve --worker-script worker.php --workers 1 --paranoid
[askr paranoid] baseline set after 2 requests — watching 95 app classes
[askr paranoid] request #42 — state changed after reset (possible bleed):
↑ App\Services\Foo::$cache  array:2 → array:3  (+1)

Silence means clean. That growing arrow means you have a leak, and exactly where.

Cache, without Redis. A shared-memory cache lives in the binary — askr_cache_* from PHP, atomic counters for rate limiting, and a drop-in Laravel driver:

Cache::increment('hits');                         // atomic across all workers
Cache::remember('report', 600, fn () => build()); // computed once, shared

Live updates, without Reverb. PHP publishes, Rust holds the connections:

askr_broadcast('orders', json_encode($order));   // from anywhere in PHP
new EventSource('/askr/events?channel=orders').onmessage = e => update(e.data);

Zero-bad-deploy. A reload rolls one worker first, health-checks it, and only rolls the rest if it stays healthy:

INFO  canary healthy — rolling the rest
ERROR canary UNHEALTHY — aborting reload; remaining workers keep old code

A broken deploy takes down one worker instead of your whole fleet.

And ~35 ms warm respawns via the CoW template — recycled workers are re-forked from the still-booted app instead of cold-booting.

Try it (it's honestly one command)

There's a self-contained release for Linux — x86_64 and arm64 — with the binary, embedded PHP, and everything else in one tarball:

VER=v0.2.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 doctor ASKR_APP_BASE=/var/www/app ./askr-run.sh serve
--root /var/www/app/public
--worker-script examples/laravel-worker.php
--workers "$(nproc)" --tls-self-signed

No Rust, no build, no libphp compilation. There's a full production setup guide with a hardened systemd unit, Let's Encrypt, and recommended settings when you're ready for real traffic.

Where it's headed (and where it isn't)

Let me be honest, because that's the fun of a side project: it's early. The I/O layer is still tokio/epoll — the per-core io_uring core is the next big step, and it's where "the most efficient PHP server" gets measured against FrankenPHP and FPM. HTTP/3, real WebSockets, and a proper askr-laravel package are on the list too.

But the thesis holds. Real Laravel, booted once, served from a memory-safe Rust binary, ~9× the FPM model — with a state-bleed detector, a shared cache, broadcasting, queue workers and the scheduler all living in one file.

I set out to answer a small, nagging question — what if the app just stayed booted? — and ended up with a server I actually want to run.

If you run Laravel, give it a spin. And if it segfaults on your box, well — that's what the Ubuntu test is for. 🌳

Askr is MIT-licensed and built by Knut W. Horne / Wirelabs AS. The name is the ash tree the gods carved the first human from — the thing that became alive and faced the world. Its dev-tool sibling is Grove: the grove where trees grow.