Search by

sailantis / stokehold-worker

lumielis

The PHP worker SDK for stokehold: serves requests over the stokehold worker protocol.

Package info

github.com/sailantis/stokehold-php-sdk

pkg:composer/sailantis/stokehold-worker

Statistics

Installs: 0

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

dev-main / 0.1.x-dev 2026-10-12 21:42 UTC

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, so echo and 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.php pins the header layout, CRC-32C and the exact golden bytes — the same literals that crates/stokehold-worker/tests/wire_format.rs in the sailantis/stokehold repository asserts, so if either implementation drifts, one of the two suites fails.
  • tests/WorkerTest.php drives a Worker over 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 each END report that request's peak rather than a figure that never falls (see above); readonly constructor 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-hash for hash('crc32c', …) — that is how the checksum is computed without reimplementing the polynomial, and it matches crc32c in Rust exactly. With the checksum option below turned on, the frame codec skips the hash entirely; nothing else in the SDK uses ext-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.