Runtime Adapters¶
One public/index.php serves a Kinetis application under FrankenPHP,
PHP-FPM, RoadRunner and AWS Lambda:
use Kinetis\Runtime\HttpStartup;
require dirname(__DIR__) . '/vendor/autoload.php';
HttpStartup::run(__DIR__);
HttpStartup detects the runtime once per boot and drives it through
its adapter. Application code does not change between runtimes; the
deployment chooses the runtime and sets the few server settings this page
lists.
Choose a runtime¶
Runtime |
Process model |
Package |
Choose it for |
|---|---|---|---|
FrankenPHP worker mode |
Each worker thread boots once and serves request after request |
core |
Production on a container or VM — Kinetis’s primary target |
PHP-FPM |
Every request runs |
core |
Development with edit-and-reload, or an existing FPM platform |
RoadRunner |
Persistent PHP worker processes behind RoadRunner’s Go server |
|
A persistent worker on a RoadRunner platform |
AWS Lambda |
A warm execution environment serves one invocation at a time |
|
An API Gateway HTTP API or a Lambda Function URL |
Detection checks frankenphp_handle_request(), then RR_MODE=http, then
AWS_LAMBDA_RUNTIME_API, and falls back to PHP-FPM. A RoadRunner or
Lambda signal without its package installed fails at startup with a
message naming the package to install.
The choice changes four things an application acts on:
Code changes. A persistent worker (FrankenPHP, RoadRunner) keeps loaded classes in memory, so an edited controller takes effect after a restart. PHP-FPM under
APP_ENV=developmentpicks it up on the next request.Request isolation. Under a persistent worker, application-scoped services outlive the request. Request data belongs in the request scope, never in a static or a shared service — see Appendix: Container Lifecycle.
Database I/O.
DB_DRIVER=autoselects the native non-blocking drivers under FrankenPHP and RoadRunner, and one blocking PDO connection under PHP-FPM and Lambda. When the overlap is real shows what that means forconcurrently().Streaming. FrankenPHP and PHP-FPM stream a
StreamedResponse. RoadRunner answers one with501, and Lambda fails the invocation.
Every runtime also needs the settings under “Request bodies: one contract
under every runtime” and, behind a proxy, TRUSTED_PROXIES.
Running under FrankenPHP¶
Worker mode needs one PHP setting and the worker script:
enable_post_data_reading=0
docker run --rm -p 8080:8080 -v "$PWD":/app -w /app \
-v "$PWD/docker/kinetis.ini":/usr/local/etc/php/conf.d/zz-kinetis.ini:ro \
dunglas/frankenphp:latest \
frankenphp php-server --listen :8080 --root /app/public --worker /app/public/index.php
enable_post_data_reading=0 is required: without it PHP consumes and
truncates the body before Kinetis runs, and the adapter refuses to serve.
kinetis/pingpong’s image uses this command and ships the same file.
Warning
FrankenPHP serves a request that matches no static file by running the
front controller classically unless a worker handles that same script.
--worker (or a Caddyfile worker directive) must name the same
public/index.php as the front controller. A different script leaves
every request on the classic path, with no error to say so.
A worker keeps public/index.php, including route discovery, loaded
across requests, so a code change needs a container restart. Use PHP-FPM
while editing code constantly.
Sizing FrankenPHP’s worker threads¶
Each worker thread processes one HTTP request at a time, start to
finish. concurrently() overlaps work inside one request; a thread
suspended on a database response is still not free to take a second
request. Cross-request concurrency is the thread count. Set it with a
Caddyfile worker block:
worker {
file public/index.php
num 20
}
or the shorthand worker public/index.php 20. Left unset, FrankenPHP
starts roughly 2× the CPU cores.
Requests dominated by waiting — slow queries, remote APIs — want
numwell above the core count, closer to the expected number of concurrent requests. Undersizing produces queueing that looks like a slow application.Requests mixing CPU with fast queries — the common case with the native drivers — want
numaround 2–3× the core count. 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.
Every thread runs bootstrap.php and the package bootstraps, so each
builds its own database pool. Keep threads × DB_MAX_CONNECTIONS under
the database’s connection limit — see Database’s “Sizing
maxConnections under worker mode” and Performance tuning.
Measure under realistic load: the two regimes want opposite corrections.
Install ext-event¶
Without a loop extension, Revolt uses a select()-based driver that
cannot watch a file descriptor numbered above 1024. The native Postgres
driver, the Redis client and the HTTP client register socket watchers,
and under FrankenPHP the Go server’s client sockets share the process’s
descriptor table, so descriptor numbers pass 1024 under load. Install
ext-event (pecl install event) in any image that uses those clients;
Revolt selects it automatically. This is a correctness requirement, not
tuning. ext-ev and ext-uv also work; ext-uv’s only release is a
beta that must be pinned (pecl install uv-0.3.0).
The native MySQL driver registers no socket watcher, but mysqli’s own poll has a separate descriptor ceiling that no loop extension lifts — see Performance tuning’s “mysqli’s poll limit”.
Running under PHP-FPM¶
PHP-FPM needs the same PHP setting, and the web server in front of it
needs a body limit at least as large as MAX_BODY_SIZE:
FROM php:8.4-fpm-alpine
WORKDIR /app
COPY docker/kinetis.ini /usr/local/etc/php/conf.d/zz-kinetis.ini
CMD ["php-fpm", "-F"]
server {
listen 8080;
root /app/public;
index index.php;
# nginx reads the body before PHP does, so this must be at least
# Kinetis's MAX_BODY_SIZE; raise both together.
client_max_body_size 2m;
location / {
try_files $uri /index.php$is_args$args;
}
location ~ \.php$ {
fastcgi_pass app:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME /app/public/index.php;
include fastcgi_params;
}
}
docker/kinetis.ini carries the enable_post_data_reading=0 shown
under FrankenPHP. kinetis/skeleton ships this setup with a
docker-compose.yml that mounts the project at /app in both
containers and names the PHP-FPM service app. Its copy of the ini also
turns PHP’s error display off, so a diagnostic reaches the container log
rather than the response body.
nginx answers a body over client_max_body_size with its own 413
before PHP runs. Its default is 1 MiB, under the 2 MiB MAX_BODY_SIZE
default, which is why the configuration sets 2m.
nginx buffers a FastCGI response and delivers it once the script ends,
which turns a streamed response — an MCP progress stream, any
StreamedResponse — into one delayed lump. Add fastcgi_buffering off;
to the PHP location, or send X-Accel-Buffering: no on the streamed
response.
Every request reruns public/index.php, so production PHP-FPM depends on
the prebuilt artifact Caching & AOT Compilation describes.
Running under RoadRunner¶
Note
Not part of core. Install it separately:
composer require kinetis/roadrunner-adapter
The package brings spiral/roadrunner-worker and spiral/roadrunner-http.
rr serve sets RR_MODE=http for the worker it spawns, which selects
the adapter. Two settings in .rr.yaml are required:
version: "3"
server:
command: "php public/index.php"
http:
address: 0.0.0.0:8080
raw_body: true
max_request_size: 10
rr serve -c .rr.yaml
http.raw_body: truehands PHP the bytes the client sent. Without it RoadRunner parses form bodies in Go and answers a body it cannot parse itself. The adapter checks this on every request and refuses to serve with an error naming the setting.http.max_request_size, in megabytes, bounds the body before PHP.
http.max_request_size is the real defense against an oversized body¶
Warning
RoadRunner reads the whole body into memory before any PHP runs, and its
default max_request_size is 1000 MB. MAX_BODY_SIZE refuses a body
Kinetis already holds; it cannot bound RoadRunner’s read, and a chunked
body with no declared length has no other bound. Set max_request_size
explicitly to what the application accepts, and set MAX_BODY_SIZE to
match (MAX_BODY_SIZE=10485760 alongside max_request_size: 10), or a
body between the two limits reaches PHP and is refused there.
ext-sockets under an Alpine-based image¶
spiral/roadrunner-worker requires ext-sockets. On an Alpine image,
docker-php-ext-install sockets also needs linux-headers:
FROM php:8.4-cli-alpine
RUN apk add --no-cache $PHPIZE_DEPS linux-headers \
&& docker-php-ext-install sockets
Sizing RoadRunner’s worker processes¶
http.pool.num_workers sets the number of PHP worker processes, each
serving one request at a time. Size it the way
Sizing FrankenPHP’s worker threads
describes: each process builds its own database pool, and the thread
ratios measured for FrankenPHP have not been re-measured for processes.
A crash in one request doesn’t take the worker down¶
An exception a handler throws is reported to RoadRunner with
Worker::error(), which answers that one client with an error, and the
worker keeps serving. A short pool.supervisor.exec_ttl still bounds a
worker’s total lifetime; RoadRunner’s default is 0s, unlimited.
What isn’t supported¶
Response streaming. A
StreamableResponseInterfaceis answered with501, never buffered or dropped silently.A purely-numeric header name such as
123. RoadRunner’s PHP library drops it before the adapter sees the request.Cookie order. Names and values arrive intact; their order may not.
Forwarded headers and the RoadRunner section of Appendix: Runtime Reference give the detection and delivery details.
Running on AWS Lambda¶
Note
Not part of core. Install it separately:
composer require kinetis/bref-adapter
BrefLambdaAdapter polls the Lambda Runtime API itself, with no
bref/bref handler in between: the one PHP process a Lambda container
runs must execute public/index.php directly, and it is that process
for which Lambda sets AWS_LAMBDA_RUNTIME_API, which the adapter reads
to select itself.
A bref/php-84 image does not do that on its own. Its CMD is a
handler string, not a command — the image’s entrypoint reads CMD into
_HANDLER and then runs /var/runtime/bootstrap, which starts Bref’s
own runtime and never reaches Kinetis’s front controller.
Replacing the runtime bootstrap¶
Replace /var/runtime/bootstrap so it runs public/index.php instead:
FROM bref/php-84:3
COPY . /var/task
COPY docker/lambda/bootstrap /var/runtime/bootstrap
RUN chmod 0755 /var/runtime/bootstrap
CMD ["public/index.php"]
#!/bin/sh
exec /opt/bin/php /var/task/public/index.php
CMD still satisfies the entrypoint’s one required argument, but
Kinetis reads no _HANDLER and ignores it. exec replaces the shell
with PHP, so PHP itself becomes the container’s runtime process. The
entrypoint itself is unchanged, and the Runtime Interface Emulator it
embeds for local invocations runs this same replacement bootstrap. Pin
bref/php-84 by tag or digest: this recipe relies on
/var/runtime/bootstrap and /opt/bin/php existing at those exact
paths.
The image must already contain vendor/ and, in production,
.kinetis-cache/compiled.php — see Caching & AOT Compilation. Lambda’s /var/task
is read-only. COPY copies only what .dockerignore permits, and the
skeleton’s own .dockerignore excludes both directories.
Invoke it through an HTTP API or a Function URL. The adapter reads payload format 2.0 only. ALB events, REST API (payload format 1.0) events, and any event that fails validation are reported to Lambda as invocation errors and never reach a route; see Streaming and Runtime API failures for what an invocation error logs and returns.
Databases use PDO.
DB_DRIVER=autoselects a blocking PDO connection. Database’s “Driver selection” covers when to selectDB_DRIVER=nativeon Lambda.The body limit is the platform’s first. API Gateway accepts and holds the whole body, up to Lambda’s 6 MB invocation payload, before the adapter runs.
MAX_BODY_SIZErefuses a larger body after delivery.The scheme is always
https. An HTTP API and a Function URL are TLS-only; an event whosex-forwarded-protoclaimshttpis refused.TRUSTED_PROXIESdoes not apply.Binary responses and cookies need no code. A response body that is not valid UTF-8 is base64-encoded for API Gateway, and every
Set-Cookiebecomes its own cookie entry.
What Lambda does not support:
Response streaming. A
StreamableResponseInterfacefails the invocation rather than being buffered. Lambda response streaming is a different invocation model this adapter does not implement.An unreachable Runtime API. A failed poll or response post throws, and the function reports an error in CloudWatch — see Streaming and Runtime API failures.
AWS Lambda events lists every validated event field, how the request’s host, port and target are derived, and how each field maps to PSR-7.
Request bodies: one contract under every runtime¶
An adapter hands the body on as raw bytes. RequestBodyMiddleware, which
runs on every request, stages it, applies MAX_BODY_SIZE and the form
limits below, and parses a form, so the same body is accepted or refused
the same way under every runtime.
Important
enable_post_data_reading=0 — FrankenPHP and PHP-FPM. PHP otherwise
reads and parses the body before Kinetis exists, silently truncating a
form at max_input_vars or emptying it past post_max_size.
SuperglobalsBridge refuses to serve without it. The setting is
PHP_INI_PERDIR: set it in php.ini, a conf.d file, .htaccess or the
FPM pool configuration, never in application code.
arg_separator.input=& — every runtime. & is PHP’s default and the
only value the form parser accepts; any other value fails the first form
parse with a server error naming the setting.
Set the edge limit with MAX_BODY_SIZE¶
MAX_BODY_SIZE (default 2 MiB) is the byte ceiling Kinetis enforces. The
server in front of PHP may read and bound the body first:
Runtime |
Limit before PHP |
Action |
|---|---|---|
FrankenPHP |
none set by Kinetis |
|
PHP-FPM behind nginx |
|
Set it to at least |
RoadRunner |
|
Set it explicitly and match |
AWS Lambda |
6 MB invocation payload |
Fixed by the platform |
Every request in progress holds its body in memory — see
Middleware’s “Request body limits” for choosing MAX_BODY_SIZE
with memory_limit.
Form limits¶
Limit |
Default |
What it counts |
|---|---|---|
|
512 |
pairs in a raw url-encoded body, and leaf values in the parsed form |
|
16 |
leaf entries in |
|
8 |
array levels a name builds — |
|
512 |
parts in a raw multipart envelope, unnamed ones included |
|
16 |
header lines on any one multipart part, repeats included |
|
8 KiB |
bytes on one multipart header line |
|
2 MiB |
bytes in the request body, whatever its content type |
MAX_BODY_SIZE is configuration. The other six are
Kinetis\Http\Form\FormLimits constants, counted from the raw body
before anything parses it.
A body past any limit is refused whole with
413, naming the limit. A form is never handed on with the fields past the limit missing.A runtime whose
max_input_varsormax_input_nesting_levelis set below these limits is413naming that setting, rather than a form PHP would have shortened.A body that cannot be parsed is
400with the fixed messageThe request body could not be parsed.Multipart bodies follow the byte-literal RFC 7578 subset browsers send; transfer encodings, encoded words, extended parameters and nested multipart parts are refused.
Both refusals happen before the handler runs. Every body is staged before the handler runs and What a multipart/form-data body may say give the staging mechanism, the exact multipart grammar and the logged failure categories.
Forwarded headers: trust only your edge¶
A client can send X-Forwarded-Proto itself. By default Kinetis reads it
from no one, and a request’s scheme is the one its listener serves.
Behind a proxy or load balancer that terminates TLS, name that edge so
absolute URLs, Secure cookies and OAuth redirects use https:
TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12
TRUSTED_PROXIESis a comma-separated list of addresses and CIDR ranges. An entry that is neither is refused at startup.Only a request whose connecting peer matches the list may set the scheme, under FrankenPHP, PHP-FPM and RoadRunner. From any other peer the header is ignored.
A trusted proxy sending anything other than one
httporhttpsis answered with400before the handler.The request’s client address stays the peer that connected.
RateLimitMiddlewarereadsX-Forwarded-Forunder its own policy — see Middleware’s “Behind a reverse proxy or load balancer”.Lambda takes no policy: its scheme is always
https.
Forwarded headers describes how each adapter applies the policy.
Choose an adapter explicitly¶
A deployment that wants one adapter rather than detection passes a
factory to HttpStartup::assemble() and serves what it returns. The
factory receives the TrustedProxies policy the container settled on:
use Kinetis\Http\TrustedProxies;
use Kinetis\Runtime\Adapters\FpmAdapter;
use Kinetis\Runtime\HttpStartup;
require dirname(__DIR__) . '/vendor/autoload.php';
HttpStartup::assemble(
dirname(__DIR__),
static fn (TrustedProxies $proxies): FpmAdapter => new FpmAdapter($proxies),
)->serve();
BrefLambdaAdapter takes the Runtime API endpoint instead:
$adapter = new Kinetis\BrefAdapter\BrefLambdaAdapter(
(string) getenv('AWS_LAMBDA_RUNTIME_API'),
);
To target another environment, implement RuntimeAdapterInterface and
hold it to the shared conformance suite — see
Writing a runtime adapter.
See also¶
Core Concepts — why application code never needs to know which adapter is running it.
Concurrency — what
concurrently()overlaps under each runtime.Caching & AOT Compilation — the production build step, and why it matters most under PHP-FPM.
Performance tuning — the worker × connection budget and tuning by workload shape.
Appendix: Runtime Reference — request-body staging, forwarded-header handling, Lambda event mapping and the custom adapter contract.
Appendix: System Layout — the framework’s runtime namespace.