Runtime Adapters¶
Kinetis runs unmodified on three different kinds of PHP hosting, and picks the right one automatically — you don’t configure this yourself:
$adapter = Kinetis\Runtime\RuntimeDetector::detect();
Deployment |
What Kinetis does |
|---|---|
FrankenPHP (worker mode) |
One long-running process serves request after request — Kinetis’s primary target. |
Plain PHP-FPM |
The classic model: one request in, one response out, then the script ends. Fully supported, not an afterthought. |
AWS Lambda (via Bref) |
A separate install, |
public/index.php calls RuntimeDetector::detect() once, and the exact
same file works correctly under all three — nothing in your application
code needs to know or care which one is actually running it.
Running under FrankenPHP¶
This is the deployment Kinetis is built around: a single PHP process that
boots once and serves thousands of requests, keeping everything warm
between them. A complete Caddyfile:
{
admin off
}
:8080 {
root * public
php_server {
worker public/index.php
}
}
docker run --rm -p 8080:8080 -v "$PWD":/app -w /app dunglas/frankenphp:latest \
frankenphp run --config Caddyfile
Note
Worker mode keeps public/index.php — including route discovery —
loaded in memory across every request it serves, so editing a controller
while the container runs has no effect until you restart it: PHP cannot
redeclare a loaded class with new content. While you are editing code
constantly, PHP-FPM’s boot-and-die model rebuilds this on every request
instead, and Kinetis falls back to it automatically with no code change.
Warning
A deployment gotcha worth knowing about: Caddy’s php_server
directive falls back to classically re-executing index.php for any
request path that doesn’t match a real static file, before it ever
routes to a configured worker. The worker directive in your Caddyfile
must point at that same index.php — pointing it at a different
script means every request silently keeps falling through to the classic
fallback, never once reaching your worker, with no error to indicate why.
Sizing FrankenPHP’s worker threads¶
FrankenPHP’s worker directive accepts an explicit thread count:
worker {
file public/index.php
num 64
}
or the shorthand form, worker public/index.php 64. Left unset, it
defaults to roughly 2x your available CPU cores — a number tuned for
CPU-bound work, not for the kind of I/O-bound workload (database calls,
outbound HTTP requests) most real applications actually spend most of
their time on.
This number matters more than it might look, and it’s easy to
mistune in both directions. Each worker thread processes exactly one
HTTP request at a time, start to finish — frankenphp_handle_request()
is a blocking call that only returns once that request’s response has
been fully sent, then picks up the next one. Kinetis’s own
Kinetis\Async/concurrently() layer (see Concurrency) provides
real, genuine concurrency within one request’s own work — but it
doesn’t change this: a thread that’s mid-request, even one suspended on
a Fiber waiting for a database response, isn’t available to pick up a
second, unrelated incoming request. Cross-request concurrency is bounded
by thread count here, the same way it’s bounded by PHP-FPM’s own
worker-process count under that adapter — not something Kinetis’s async
layer can substitute for.
Which direction to tune depends on what your requests actually wait on:
Requests dominated by genuine waiting — slow queries, remote APIs, anything where the thread sits idle for tens of milliseconds — want
numwell above the core count, closer to expected concurrent request volume. Undersizing here doesn’t produce errors; it produces queueing that looks, from the outside, exactly like the application being slow.Requests mixing CPU with fast queries — the common case with
kinetis/persistence’s native drivers, where each query is sub-millisecond but a request still spends real wall time suspended across its fan-out — wantnummoderately above the core count, around 2–3×. On an 8-vCPU host against a sub-millisecond database, 20 threads outperform 8 on every database-touching route (a 20-query fan-out by ~10%, single-query routes by ~9%) with no loss on CPU-pure routes. Go far beyond that and two costs take over: context-switch overhead, and — usually first — the per-thread database pool budget below.
Either way: measure under realistic load rather than guessing — the two regimes want opposite corrections, and which one you’re in is a property of your routes, not of the framework.
The same “each worker thread is its own independent execution context”
fact has a second, sharper consequence if you’re using
kinetis/persistence’s SqlConnectionFactory: bootstrap.php runs once
per worker thread, so each one builds its own separate database
connection pool. Oversizing num without correspondingly undersizing
each pool’s maxConnections can exhaust your database’s own connection
limit — see Persistence’s “Sizing maxConnections under worker
mode” section.
The default event-loop driver’s file descriptor limit¶
Kinetis’s concurrency primitives (see Concurrency) run on
Revolt’s event loop. Without a driver extension installed, Revolt falls
back to a driver backed by the C select() system call, which can only
track file descriptors numbered up to 1024 — a fixed ceiling, not
something raised by configuration.
Whether that ceiling can bite depends on what the loop actually
watches. The native MySQL driver watches no file descriptors at all
(mysqli exposes none; it bridges via polling), so a MySQL-only
deployment never hits this ceiling — though mysqli’s own polling
carries a separate select()-based limit that no loop extension lifts;
see Performance tuning’s “mysqli’s poll limit” for the
constraint and the boot-time pool warming that addresses it. The native Postgres driver, the Redis
client, and the HTTP client all register real socket watchers — and
under FrankenPHP the embedded Go server’s client sockets share the same
process-wide fd table, pushing fd numbers past 1024 under load even
with few PHP worker threads. Any deployment in that second group should
install one of Revolt’s supported extensions — ext-uv, ext-ev, or
ext-event — each backed by an OS-native mechanism (epoll on Linux)
with no fd-number ceiling. Revolt selects whichever is available
automatically, with no application code to change.
This is a correctness concern, not a performance one — measured
throughput is identical across drivers for typical workloads; what the
extensions buy is not being at the mercy of fd numbering. ext-event
has the smoothest install story on current PECL (pecl install event);
ext-uv works too but its only release must be pinned explicitly
(pecl install uv-0.3.0 — PECL refuses non-stable packages by
default).
Running under PHP-FPM¶
Nothing to configure — Kinetis detects a plain PHP-FPM environment
automatically and falls back to it whenever FrankenPHP isn’t available.
Every request reruns the whole public/index.php script from scratch,
since PHP-FPM doesn’t keep anything in memory between requests. See
Caching & AOT Compilation for what changes about that in production, and why it
matters more here than under FrankenPHP.
One setting matters for a streamed response (an MCP progress stream,
any StreamedResponse): nginx buffers a FastCGI response by default and
delivers it whole once the script ends, which turns a stream into a
delayed lump. Set fastcgi_buffering off; in the location that proxies
to PHP-FPM — or have the response carry X-Accel-Buffering: no. The
conformance suite’s FPM run (see Testing) fails without it, on
purpose.
Both this adapter and the FrankenPHP one run the shared runtime
conformance suite against their real SAPI in CI — a FrankenPHP worker
behind Caddy, PHP-FPM behind nginx — not only against the php -S
stand-in the committed unit suite uses.
Running on AWS Lambda¶
Note
Not part of core. Install it separately:
composer require kinetis/bref-adapter
Once installed, detection picks it up automatically — nothing else to configure. It needs one extra dependency beyond what core ships with (for parsing file uploads), which is why it’s a separate install rather than bundled by default.
Kinetis\BrefAdapter\BrefLambdaAdapter speaks the Lambda Runtime API
directly (poll .../invocation/next, run the request, post the response
to .../invocation/{id}/response) and converts to/from API Gateway’s
HTTP API payload format 2.0 event shape — the format a Function URL
or an HTTP API (as opposed to the older REST API) integration sends.
ALB and the older REST API’s payload format 1.0 aren’t handled.
Every field this depends on is validated before an event is ever routed
— a direct Lambda invocation (not just API Gateway) can carry arbitrary
JSON, so this is checked, not assumed. "version": "2.0" is required
and checked explicitly, specifically because it’s the one field that
tells a genuine payload-v2 event apart from anything else that happens
to be shaped similarly — a payload-format-1 event carrying a
coincidentally (or deliberately) v2-shaped requestContext.http would
otherwise pass a check that only looked at that nested shape.
rawPath and requestContext.http.method are required as non-empty
strings; every other field this adapter reads (rawQueryString,
headers, queryStringParameters, body, isBase64Encoded,
requestContext.http.sourceIp, cookies) is optional but, when
present, is checked for the right type — including the exact
collection shape, not just “is this an array”: headers and
queryStringParameters must each be a genuine JSON object with
string values (an array-valued entry, or the field being a JSON list
instead of an object, is rejected), and cookies must be a genuine
JSON list of strings (a JSON object — {"session": "abc"} rather
than ["session=abc"] — is rejected, not silently accepted as a
one-entry list). This distinction only exists at the raw JSON level:
json_decode(..., associative: true) collapses {} and [], and an
object-valued and a string-valued map entry, into the identical shape
of plain PHP array — so validation runs against a separate,
non-associative decode of the same body first, which is the only
decode mode where a JSON object and a JSON list actually stay
distinguishable. Anything that fails any of these checks is rejected
outright, reported to the Runtime API’s invocation error endpoint —
never silently degraded into a plausible-looking request built from
whichever fields happen to be missing or malformed.
What’s mapped, and how¶
Method, path, query string, and headers — straight from the event’s own
requestContext.http.method/rawPath/rawQueryString/headers. A purely-numeric header name ("123", valid per RFC 9110 — digits are ordinary token characters) is mapped correctly: PHP’s ownjson_decode(..., associative: true)coerces a canonical-integer JSON object key into a real PHP int array key, so it’s cast back to a string before reaching PSR-7’swithHeader(), which requires one.queryStringParametershas the identical coercion and no equivalent fix — PHP always coerces a canonical-integer string used as an array key back to int, regardless of any cast applied first, unlike a genuine function-argument cast — but this is harmless rather than disclosed as a gap:withQueryParams()never rejects an int key the waywithHeader()rejects an int argument, and PHP’s own array-lookup semantics coerce a numeric-string read the identical way, so$request->getQueryParams()['123']still finds the value regardless of which key type is actually stored.Cookies — payload format 2.0 carries these as their own top-level
cookies: string[]list, never folded intoheaders. Reconstructed into a realCookieheader and intogetCookieParams(), so cookie- and session-based authentication (see Sessions & CSRF) works the same as it does under FrankenPHP or FPM.The client’s IP address —
requestContext.http.sourceIpis mapped to the request’sREMOTE_ADDRserver parameter. Nothing else here has one: every invocation arrives over the Runtime API, not a socket PHP itself accepted, so without this every request would look identical to code readingREMOTE_ADDR(RateLimitMiddleware’s identifier for one — see Middleware — and any per-client logging).The request body — a base64-encoded body (
isBase64Encoded: true) is decoded strictly: invalid base64 is answered with a400rather than silently becoming an empty body.multipart/form-dataandapplication/x-www-form-urlencodedbodies are parsed intogetParsedBody()/getUploadedFiles()the same way core’s own adapters parse a form body, and a multipart body with no usable boundary gets the same400— the identical response, with the identical fixed message,SuperglobalsBridgegives a bodyrequest_parse_body()rejects under FrankenPHP or FPM. Either way the real reason is logged, never returned, and the handler never runs. There is no SAPI here to enforcepost_max_size: the only cap on a form body is Lambda’s own invocation payload limit (6 MB).MaxBodySizeMiddleware’s cap on a raw JSON body applies as everywhere else.The response body — checked for valid UTF-8 before being handed to the Runtime API, which receives the whole response as one JSON document. A body that isn’t valid UTF-8 (an image, a PDF, any binary payload) is base64-encoded and
isBase64Encoded: trueis set on the payload — API Gateway decodes it again on the way out. A body that’s already valid UTF-8 is sent as-is.Response cookies — every
Set-Cookieheader value is emitted as its own entry in the payload’scookiesarray, never comma-joined with any otherSet-Cookievalue into one header. This matters because a cookie’s own attributes (Expires, in particular) already contain a comma, so folding two cookies together the way ordinary repeated headers are folded here would produce a value no client could parse back into distinct cookies.
What isn’t supported¶
Response streaming. The Runtime API’s poll/respond contract is strictly one invocation → one response payload; a controller returning a
Kinetis\Runtime\StreamableResponseInterfacethrows immediately rather than silently buffering or dropping the stream. Real Lambda response streaming needs a Function URL configured withInvokeMode: RESPONSE_STREAM, a different invocation model this adapter doesn’t implement.ALB and REST API (payload format 1.0) events. Only the HTTP API’s format 2.0 shape is understood — see the event-validation paragraph above for exactly what’s checked and how an unsupported or malformed event is reported.
A Runtime API the adapter can’t reach. A poll or a response POST that fails outright (connection refused, a non-2xx status) throws instead of being treated as an empty response — there is no invocation to serve and nothing meaningful to fall back to, so surfacing the failure (visible in CloudWatch as the function erroring) is the correct outcome rather than continuing silently.
Writing your own adapter¶
If you need to target something else entirely, implement this interface and Kinetis will drive it the same way it drives the three built-in ones:
namespace Kinetis\Runtime;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
interface RuntimeAdapterInterface
{
/**
* @param callable(ServerRequestInterface): ResponseInterface $handler
*/
public function run(callable $handler): void;
public function isPersistent(): bool;
}
isPersistent() tells Kinetis whether to force a memory cleanup pass at
the end of every request — worth doing in a long-running process, pure
waste in one that’s about to exit anyway.
Then hold it to the same contract as the built-in ones: implement a
Kinetis\Testing\Runtime\RuntimeAdapterDriver for it and extend
RuntimeAdapterConformanceTestCase — see Testing. Every behavior
the core adapters agree on (how a repeated header folds, where cookies
land, form and binary bodies, response cookies, streaming, the 400 for
a body the environment can’t parse — whose fixed message is
RuntimeAdapterInterface::MALFORMED_BODY_MESSAGE) runs against yours
with no further test code.
You can also construct any adapter directly if you want to force a specific one instead of relying on automatic detection:
$adapter = new Kinetis\Runtime\Adapters\FpmAdapter();
See also¶
Core Concepts — why your application code never needs to know which adapter is running it.
Concurrency — what
Kinetis\Async/concurrently()actually provides, and what it doesn’t.Caching & AOT Compilation — the production build step, and why it matters most under PHP-FPM.
Appendix: System Layout — the exact internals of each built-in adapter.
Performance tuning — the worker-threads x connections budget, what to observe under load, and tuning by workload shape.