Run a Symfony / HttpKernel application under ePHPm persistent worker mode through Symfony's own Runtime component.
This package is a custom Symfony RuntimeInterface — the same extension point
the FrankenPHP and RoadRunner runtimes use. Select it and your existing
public/index.php front controller keeps the kernel bootstrapped in memory and
services each HTTP request from a long-lived ePHPm worker, avoiding per-request
framework bootstrap cost. No changes to your controllers, routing, or kernel.
Symfony's Runtime component splits an app into a resolver (build the app object) and a runner (run it). This package ships:
Ephpm\Symfony\Runtime— a subclass ofSymfonyRuntime. It inherits the entire Symfony boot contract (APP_ENV/APP_DEBUG,.envloading viasymfony/dotenv, the error handler, kernel-closure argument resolution) and changes only which runner serves anHttpKernelInterface.Ephpm\Symfony\HttpKernelWorker— aRunnerInterfacethat loops on the nativeEphpm\Worker\take_request(), builds a SymfonyRequestfrom each ePHPm request Envelope, dispatches it throughHttpKernel::handle(), sends theResponseback to the engine, then callsHttpKernel::terminate()sokernel.terminatepost-response work runs.
Selecting this runtime is harmless off ePHPm: when the native worker
primitives are absent (a normal PHP-FPM/CLI request, bin/console), the runtime
falls back to Symfony's standard runner, so the same front controller works
everywhere.
ePHPm packages are distributed via their GitHub repositories (not Packagist);
symfony/* come from Packagist as normal. Add every ePHPm repo in the
dependency tree as a Composer vcs repository, then require the adapter. This
package pulls in ephpm/worker, so both ePHPm repos are listed — Composer
does not resolve a VCS dependency's own VCS repositories transitively, so
each ePHPm package needs its own repositories entry in your app's
composer.json.
{
"repositories": [
{ "type": "vcs", "url": "https://github.com/ephpm/symfony-runtime" },
{ "type": "vcs", "url": "https://github.com/ephpm/php-worker" }
],
"require": {
"ephpm/symfony-runtime": "^0.1"
}
}Both ephpm/symfony-runtime and its ephpm/worker dependency are tagged
v0.1.0, so ^0.1 resolves for each; each still needs its own repositories
entry because Composer does not resolve VCS repos transitively. Then:
composer updatePin the runtime class the canonical Symfony way — in your app's composer.json,
then regenerate vendor/autoload_runtime.php:
{
"extra": {
"runtime": { "class": "Ephpm\\Symfony\\Runtime" }
}
}composer dump-autoloadNo environment variable is needed — the class is baked into
vendor/autoload_runtime.php. Alternatively, set the APP_RUNTIME environment
variable to Ephpm\Symfony\Runtime on the ePHPm server process (it overrides
the composer.json default when exposed to PHP as $_SERVER/$_ENV).
Your public/index.php needs no changes — the standard Symfony front
controller is runtime-agnostic:
<?php
// public/index.php
use App\Kernel;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return function (array $context) {
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};Point [php.worker] script at your front controller:
[php]
mode = "worker"
# concurrency = 0 # worker-thread pool size (0 = derived from CPU/cgroup quota)
[php.worker]
script = "public/index.php"The worker knobs live in the [php.worker] table (script, max_requests,
boot_timeout, populate_superglobals, stream_threshold); the pool size is
[php] concurrency (a whole-server scheduling knob, 0 = auto). Leave
populate_superglobals off — this adapter builds the Symfony Request from the
request Envelope and does not read $_GET/$_POST/$_FILES.
[php.worker] scriptmust resolve to a file underdocument_root, so keeppublic/index.php(or your chosen front controller) inside the document root.
If you prefer an explicit worker script over the Runtime-component wiring (e.g.
you can't set APP_RUNTIME/extra.runtime.class, or want an entry that doesn't
depend on vendor/autoload_runtime.php), point [php.worker] script at the
bundled bin/ephpm-symfony-worker and name a bootstrap that returns an
HttpKernelInterface:
[php.worker]
script = "vendor/ephpm/symfony-runtime/bin/ephpm-symfony-worker"EPHPM_SYMFONY_BOOTSTRAP=worker-kernel.php ephpm serve --config ephpm.toml<?php
// worker-kernel.php
use App\Kernel;
require_once __DIR__.'/vendor/autoload.php';
(new Dotenv())->bootEnv(__DIR__.'/.env'); // if you use symfony/dotenv
return new Kernel($_SERVER['APP_ENV'] ?? 'prod', (bool) ($_SERVER['APP_DEBUG'] ?? false));This path drives Ephpm\Symfony\HttpKernelWorker directly and never touches the
Symfony Runtime resolver, so runtime selection is deterministic regardless of
environment propagation.
ePHPm hands adapters raw request material only — it never parses request bodies
(Envelope::parsedBody() is always null, Envelope::files() is always empty)
and its query/cookie arrays are split on delimiters but not url-decoded. This
adapter fills the gap, exactly like the sibling ephpm/psr15-worker:
- Query string is re-parsed with
parse_str()(url-decoding plusa[]=bracket arrays) into$request->query. - Cookies have their names and values url-decoded.
application/x-www-form-urlencodedbodies (POST/PUT/PATCH/DELETE) are parsed into$request->request.multipart/form-databodies are parsed into fields plus SymfonyUploadedFileinstances (in test mode, soisValid()/move()work over the spooled temp files — the same approach Symfony's own PSR-7 bridge uses); uploads are spooled to temp files removed automatically after each request. Aname="photos[]"field becomes a list ofUploadedFile, not a last-wins collapse.- JSON / raw bodies are left untouched — read
$request->getContent()yourself. Set-Cookieis emitted as one wire header per cookie (Symfony keeps cookies in a separate bag; each is rendered to its wire form). Other repeated response headers are comma-joined per RFC 9110.
Responses stream when it pays to:
BinaryFileResponseis streamed straight from disk with flat memory, regardless of file size.StreamedResponse(and any response whosegetContent()is not a string) has its output captured into a memory-bounded temp spool, then streamed.- Plain string bodies larger than 1 MiB are spooled and streamed so the engine forwards them to the client in chunks with backpressure; smaller bodies are sent buffered.
The request body is currently buffered as a string via Envelope::rawBody();
incremental request-body consumption (Envelope::bodyStream()) is available
from the engine but not yet used by this adapter.
Like every HttpKernel worker adapter (Octane, FrankenPHP, RoadRunner), the
kernel and its services are reused across requests — that reuse is the whole
point of worker mode. Your application owns its per-request state. Reset
anything request-scoped in a reset/kernel.terminate listener, and rely on
[php.worker] max_requests to recycle a worker periodically so slow growth in
long-lived framework state is reclaimed with a clean reboot. This adapter does
not reboot the kernel per request.
A throwable that escapes HttpKernel::handle() (which normally renders its own
error responses), or one from request/response translation, becomes a generic
500 so one bad request cannot kill the loop — unless a response was already
sent, in which case it is rethrown so ePHPm recycles the worker rather than
risking a double-send. kernel.terminate runs only after a successful send.
MIT — see LICENSE.