A PHP application server. libphp is linked into a Rust binary, with a custom SAPI and hyper
on top. No FastCGI, no reverse proxy, no process manager.
Proof of concept. It is tested like production software. 31 conformance cases diffed byte for byte against php-fpm, 21 fault injections, 6 streaming checks, 13 unit tests, a 10 minute soak. All of it passes. It is still not production software. One contributor, no deployment history, no security review, no HTTP/3. Use FrankenPHP or php-fpm for anything real.
Symfony 7.2, prod, warm cache, 6 workers, 64 connections, median of 3 runs.
| stack | route | rps | p50 | p99 | memory |
|---|---|---|---|---|---|
| stoke worker | / |
59 386 | 0.96 ms | 4.30 ms | 81 MB |
| frankenphp worker | / |
24 574 | 2.31 ms | 7.59 ms | 196 MB |
| stoke worker | /json 100 KB |
8 971 | 6.47 ms | 18.31 ms | 83 MB |
| frankenphp worker | /json 100 KB |
7 278 | 8.50 ms | 16.31 ms | 199 MB |
| stoke classic | / |
4 857 | 13.29 ms | 28.82 ms | 50 MB |
| php-fpm + nginx | / |
4 046 | 15.53 ms | 25.93 ms | 40 MB |
CPU per request: stoke 105 µs, FrankenPHP 252 µs, php-fpm 1 952 µs. The p99 on large responses changes sides between runs, so treat those two as tied.
Measured in a Docker Desktop VM on an Apple M4. The numbers are only valid relative to each other. Every stack ran in the same container, on the same application, in the same session.
PHP runs on the thread hyper hands the request to. No channel, no wakeup. The engine is not thread-safe, so the server forks instead of using threads.
The app is booted before forking. Workers share its heap copy-on-write: one worker costs 38 MB, sixteen cost 44 MB.
$_SERVER barely changes between requests, so it is copied from a persistent hashtable
instead of registered entry by entry.
docker compose -f docker/compose.yml up -d dev
docker compose -f docker/compose.yml exec dev cargo build --release
STOKE_ROOT=/app/public stoke # classic
STOKE_ROOT=/app/public STOKE_WORKER_SCRIPT=/app/worker.php stoke # workerA worker script returns a callable:
<?php
$kernel = new Kernel('prod', false);
$kernel->boot();
return function () use ($kernel) {
$request = Request::createFromGlobals();
$response = $kernel->handle($request);
$response->send();
$kernel->terminate($request, $response);
};Worker scripts written for FrankenPHP run unmodified. frankenphp_handle_request($handler) is
accepted and takes the callable.
Return an array instead to get a per-worker start hook. It runs once in each forked worker. That is where you reopen anything the pre-fork bootstrap left shared:
return [
'on_worker_start' => fn () => $pool->reconnect(),
'handler' => fn () => $kernel->handle(Request::createFromGlobals())->send(),
];Settings come from the environment. STOKE_CONFIG points at a file using the same names
without the prefix (workers = 4). The environment wins over the file.
| variable | default | |
|---|---|---|
STOKE_LISTEN |
0.0.0.0:8080 |
|
STOKE_ROOT |
/app/public |
document root |
STOKE_WORKERS |
CPU count | worker processes |
STOKE_WORKER_SCRIPT |
— | enables worker mode |
STOKE_FRONT_CONTROLLER |
— | classic-mode fallback script |
STOKE_TLS_CERT / STOKE_TLS_KEY |
— | enables TLS, ALPN negotiates HTTP/2 |
STOKE_STREAM |
0 |
send output as PHP produces it. Needed for SSE and long polling, costs about 10% throughput |
STOKE_STATIC |
1 |
serve static files |
STOKE_MAX_BODY |
8 MiB | request body limit |
STOKE_LOOP_MAX |
0 |
recycle a worker after N requests |
STOKE_PREFORK_BOOT |
1 |
boot the app before forking, so workers share its memory |
STOKE_INI |
— | extra php.ini directives, comma separated |
STOKE_CONFIG |
— | path to a settings file |
HTTP/1.1 and HTTP/2 share the port, with or without TLS.
The test suites run with sh tests/run-all.sh inside the dev container.
HTTP/3 and automatic certificates are not planned. That is the proxy's job. No point rebuilding Caddy.
Two things to know. With STOKE_STREAM=1, a client that stops reading blocks its worker until
max_execution_time. Every unbuffered server does that. With STOKE_PREFORK_BOOT=1, workers
share whatever the bootstrap opened. The on_worker_start hook is there to reopen it,
otherwise set the flag to 0.