sailantis / stokehold-worker
The PHP worker SDK for stokehold: serves requests over the stokehold worker protocol.
Requires
- php: >=8.2
- ext-hash: *
- ext-json: *
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-12 21:42:44 UTC
README
The PHP end of the stokehold worker protocol: a persistent PHP process that boots your application once and serves many requests over stdin/stdout.
The server itself — the process that spawns and supervises workers — is
sailantis/stokehold. This package is only the PHP
half; it is useful on its own but does nothing without a server to speak to.
The protocol link above is pinned to a commit rather than to a branch: a branch link would silently start describing a later protocol than the one this release implements.
It is stdlib-only. No extensions, no FFI, no build step, and no Composer requirement just to run a worker. The whole SDK is four small classes and a codec.
What it is not
It is a transport, not a framework. There is no router, no container, no middleware pipeline,
and no PSR-7 dependency — because forcing one would mean this package could not be used from the
many applications that use a different one. Request and Response are plain value objects;
adapting them to whatever your application already speaks is a few lines.
Quick start
<?php require __DIR__ . '/vendor/autoload.php'; // or: require 'path/to/stokehold-worker/autoload.php'; use Stokehold\Request; use Stokehold\Response; use Stokehold\Worker; Worker::connect()->run(function (Request $request): Response { return Response::text('hello ' . $request->uri); });
Start it with php worker.php. It will sit waiting, because a worker speaks the protocol over
stdin/stdout rather than HTTP — point stokehold at it, or see
Example for how to poke it by hand.
Worker::connect() is Worker::create() with one extra step: when the server has set
STOKEHOLD_WORKER_SOCKET — which it does on the socket transport, the default on Windows —
the protocol runs over that loopback socket instead of stdio. Use connect() in a worker that a
stokehold backend starts, and create() when you are binding streams yourself.
This is not cosmetic on Windows. A worker that calls create() cannot read a socket, so under
the default transport it reads php://stdin — which the server has set to end-of-file — and exits
during its handshake. The server reports that at startup, naming connect() as the fix, so it
fails fast and loudly rather than hanging; but connect() is the call to use. It is backward
compatible either way: with no address in the environment it is exactly create().
The two rules
Both are enforced by the SDK, not merely documented. They are the two things that go wrong in every persistent-PHP protocol, so it is worth knowing they are handled for you.
1. stdout is the protocol channel
Every frame goes to php://stdout. Anything the application writes there would land inside a
frame and desynchronise the stream permanently.
So application output is not banned — it is captured:
ob_start()runs around each request, soechoand template output are collected rather than written.ini_set('display_errors', 'stderr')sends warnings and notices to stderr, which is not part of the protocol.
The captured output becomes the response body when your handler returns null. That means a
plain PHP script works as a worker without being changed — if it echoes, that echo is the
response. If your handler returns a Response, the captured output is discarded.
To talk to the server outside a request, use emitLog() / emitMetric(). Those are the reason
this protocol exists instead of using FastCGI: a worker can speak first.
The residual risk is a write made directly to the STDOUT constant
(fwrite(STDOUT, …), file_put_contents('php://stdout', …)), which no output buffer can
intercept. Nothing in PHP or any framework produces output that way, and if it happens the
consequence is a checksum mismatch the server reports clearly rather than silent corruption.
2. Shutdown by construction
When the server wants the worker to stop it closes the child's stdin. The loop reads that as a
clean EOF and returns from run(), so the worker exits on its own. There is no signal to
catch and no flag to poll — which is why the server never has to ask twice before killing.
Writing a handler
The handler receives the Request and returns one of:
| Return | Result |
|---|---|
Response |
sent exactly as given |
string |
sent as a 200 text/html body |
null |
the captured output becomes the body |
If the handler throws, the SDK logs it to stderr, answers 500, and keeps serving. One bad
request does not take the worker down.
Worker::create()->run(function (Request $request, Worker $worker): ?Response { // Telemetry never touches the response. $worker->emitMetric('request.count', 1.0, 'count'); $worker->emitLog("serving {$request->uri}"); if ($request->uri === '/health') { return Response::json(['status' => 'ok']); } // Echo case: the captured output is the body, so a plain script works. echo 'you asked for ', htmlspecialchars($request->uri, ENT_QUOTES); return null; });
Request
| Member | Notes |
|---|---|
method, uri, protocol |
as sent |
headers |
name => value; header($name) looks up case-insensitively |
body |
the raw request body |
contentType() |
shorthand for the Content-Type header |
query() |
the query string parsed the way PHP populates $_GET |
cookies() |
the Cookie header parsed the way PHP populates $_COOKIE |
serverVars() |
CGI-style variables derived from the request (for legacy code) |
remoteAddr, scriptFilename, https |
as sent |
Response
Response::text('plain'); // 200, text/plain Response::html('<h1>hi</h1>'); // 200, text/html Response::json(['ok' => true]); // 200, application/json new Response(404, [['Content-Type', 'text/plain']], 'not found'); $response->withStatus(201)->withHeader('X-Trace', 'abc')->withBody('…');
withHeader() appends rather than replaces, so Set-Cookie can be sent more than once.
Worker
| Method | Purpose |
|---|---|
Worker::create($in?, $out?) |
bind explicit streams (default: stdio), force display_errors=stderr |
Worker::connect() |
create(), but honour STOKEHOLD_WORKER_SOCKET when the server sets it |
run($handler) |
handshake, then serve until EOF |
emitLog($message, $level) |
one log record, unsolicited |
emitMetric($name, $value, $unit) |
one metric sample, unsolicited |
recycle() |
ask the server to replace this worker after the request |
currentRequestId() |
the in-flight request id, for telemetry correlation |
recycle() is what an application calls when it has put itself into a state it cannot clean up —
a leaked global, a stuck connection, a fat static cache. The server sees keep_alive = false in
END and boots a replacement; the request that asked is still answered normally, so a handler
never has to choose between responding and being cleaned up.
Each END frame reports peak_memory_bytes for that request, not for the process. The worker
reads memory_get_peak_usage(true) and then calls memory_reset_peak_usage(), so a spike in one
request does not make the server recycle the worker after every later one. That reset exists on
PHP 8.2+, which is the SDK's floor; a version below the stated minimum would leave the figure cumulative, so a
memory_limit_mb threshold recycles more eagerly there. The same figure is available between
requests via the report control.
Example
example/worker.php is a runnable worker with /, /health, /echo,
/big, /boom and /recycle routes — including a route that throws (to show that a failing
request does not kill the process) and one that asks to be recycled. /echo also echoes the raw
request body back, which is how the pool tests confirm a body arrived as bytes rather than merely
arrived.
To drive it by hand you need something that speaks the protocol; the test below does exactly
that, over a real pipe. It lives in the server repository, because it is the Rust side that
drives this PHP file — clone
sailantis/stokehold and run it there:
cargo test -p stokehold-worker --test php_worker
And the server side of the contract — spawning, handshaking, recycling, shutdown — is covered against real worker processes by:
cargo test -p stokehold-server --test worker_pool
Those tests are also the honest answer to "how do you know it works": a Rust client writes
REQUEST frames into a live php worker.php and reads the RESPONSE back, so both implementations
are held to the same wire format with a real process in between. They skip themselves, with a
printed reason, when no php is on PATH.
Tests
php tests/run.php # 124 assertions, no PHPUnit needed
tests/WireFormatTest.phppins the header layout, CRC-32C and the exact golden bytes — the same literals thatcrates/stokehold-worker/tests/wire_format.rsin thesailantis/stokeholdrepository asserts, so if either implementation drifts, one of the two suites fails.tests/WorkerTest.phpdrives aWorkerover in-memory streams: output capture, chunking, request bodies, control frames, error recovery, and the framing failures.
Running the suite prints a WARNING-MARKER line on stderr. That is the mechanism working, not a
failure: the warning went to stderr instead of into a frame.
Requirements and versions
- PHP 8.2+ — the floor is
memory_reset_peak_usage(), which the SDK calls to make eachENDreport that request's peak rather than a figure that never falls (see above);readonlyconstructor promotion is the syntax floor and is older. Verified passing the full suite on 8.2.23, 8.3.31, 8.4.24 and 8.5.9. PHP 8.0 and 8.1 are below the floor and untested; 8.0 additionally fails to parse the SDK. ext-hashforhash('crc32c', …)— that is how the checksum is computed without reimplementing the polynomial, and it matchescrc32cin Rust exactly. With the checksum option below turned on, the frame codec skips the hash entirely; nothing else in the SDK usesext-hash, so a deployment that always turns it off could in principle drop the extension.ext-json.
The protocol version is 1. A worker and a server that disagree are caught at the handshake
rather than on the first request.
Skipping the checksum
A server may offer, in its HELLO, to let the worker stop computing the per-frame CRC-32C. The
SDK accepts only when the field is the JSON literal true — a 1, a "yes" or a missing key all
read as "no", because a field that switches off an integrity check should take exactly one
spelling — and it echoes the answer in its HELLO_ACK. Both ends verify the handshake itself, so
the exchange that carries the agreement is protected; only the frames after it are unchecked.
Nothing has to be configured here: an older server sends no such field and the worker keeps
checksumming. And a server that sends an unchecked frame to a worker that never agreed gets a
protocol error rather than a silent downgrade. See
docs/worker-protocol.md
for the wire details.
The build is separate from the protocol version and travels in the handshake's runtime field as
stokehold-worker-php/<build>, so which build a worker is running is visible from the server rather than
only from the host. It is deliberately not a protocol change: runtime is a free-form string, so a server
that ignores it behaves exactly as before.
Worker::build() asks Composer about this package by name — the three cases that produces:
| Situation | build() |
Why |
|---|---|---|
| Installed as a release | 0.1.1+7c6945f |
The version names it, the commit pins it exactly |
A dev-main requirement |
7c6945f |
No meaningful version, so the commit is the build |
| No Composer at all | unknown |
A bare php worker.php never went through Composer |
It is asked by name rather than through getRootPackage(), which returns the consuming application —
that would report the host's build under the worker's own name, which is a plausible-looking answer and the
wrong one. The last row is not a failure: a worker that cannot name its build can still serve requests, and
should not refuse to start over a diagnostic.
There is deliberately no Worker::VERSION constant, and no tag-based check in the suite. While the API is
settling, main is the artifact, and a version literal would be one more thing to keep true by hand on
every change. The commit identifies the build, and it is the same reference a consuming application's
composer.lock pins — so what a worker reports is what the application can be reproduced from.
The protocol version and the build move independently: a fix that changes no wire behaviour is a new commit, while a wire change is a protocol version and a release.
The SDK relies on stream_get_contents($stream, $n) for exact reads, which blocks until it has
$n bytes or reaches EOF and handles partial reads internally. That is deliberate: hand-rolling
an fread loop is the bug-prone part of every pipe protocol, and this call is why the SDK does
not contain one.
License
MIT — see LICENSE.