Symfony Runtime: Native ePHPm Adapter
A roadmap for a first-class ephpm runtime adapter under
symfony/runtime. Symfony Runtime is the framework’s built-in seam for
swapping the entry point — PHP-FPM, Swoole, RoadRunner, FrankenPHP, ReactPHP,
and AWS Lambda (Bref) all plug in via the same RunnerInterface. An ephpm
adapter is what lets a stock Symfony / API Platform / Drupal-on-Symfony app
opt into worker mode without leaving the binary.
Status: the adapter package is planned — not yet implemented. Its Rust-side
prerequisite shipped in 3.0: the worker-mode engine
and its \Ephpm\Worker\* primitives are live, and the
Laravel Octane driver is already built on them
(nothing Symfony-specific is needed in Rust). This document focuses on
what makes the Symfony side different.
Why a Native Adapter
Symfony already has runtime adapters for the obvious backends — they live in
the php-runtime/runtime community
org under runtime/frankenphp-symfony, runtime/swoole, runtime/reactphp,
runtime/bref, etc. None of them know about ePHPm, so Symfony users currently
get the same suboptimal options Octane users get:
| Adapter | Backend | Cost to ePHPm |
|---|---|---|
runtime/swoole | Swoole extension + its own server | Run a second PHP server alongside ephpm — no point. |
runtime/roadrunner | Go process + binary protocol | Reintroduces a TCP/pipe hop in front of in-process PHP. |
runtime/frankenphp-symfony | C extension exposing frankenphp_handle_request() | Closest contract; we could shim it but inherit FrankenPHP semantics. |
runtime/reactphp | Userland PHP event loop | No FFI advantage; same cost as Octane Swoole. |
A native ephpm adapter keeps PHP fully in-process. The fit is even
cleaner than the Octane case because Symfony does most of the
state-management work inside the framework already:
| Symfony already has | ePHPm provides |
|---|---|
kernel.reset tag + ResetServicesListener | Just call Kernel::handle() in a loop — services reset themselves between requests |
RuntimeInterface / RunnerInterface | A blocking take_request SAPI call to drive the runner |
| Built-in PSR-7 / Symfony HttpFoundation request and response | The same Symfony\Request we pass through |
| Long-lived process patterns (Messenger workers) | Tokio blocking pool — same model |
The result: the entire Symfony adapter is ~100 lines of PHP, not a package the size of Octane. Most of it is parameter wiring.
Architecture
┌──────────────────────────────────────────────────────────────────────┐
│ ephpm process │
│ │
│ hyper ──► router ──► spawn_blocking ──► PHP worker thread │
│ │ │
│ ▼ │
│ ┌──────────────────────────────┐ │
│ │ TSRM context (per thread) │ │
│ │ │ │
│ │ public/index.php (entry) │ │
│ │ ├─ APP_RUNTIME=…\Runtime │ │
│ │ ├─ Kernel boot (once) │ │
│ │ └─ ephpm Runner │ │
│ │ │ │
│ │ loop { │ │
│ │ $req = ephpm_take_req() │ │
│ │ $resp = $kernel->handle()│ │
│ │ ephpm_send_resp($resp) │ │
│ │ $kernel->terminate() │ │
│ │ // kernel.reset listeners│ │
│ │ // fire automatically │ │
│ │ } │ │
│ └──────────────────────────────┘ │
│ ▲ │
│ │ SAPI bindings │
│ ┌───────────────┴───────────────┐ │
│ │ ephpm-kv (ephpm_kv_* funcs) │ │
│ │ ephpm-cluster │ │
│ │ ephpm-db (pooled connections)│ │
│ └───────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘The adapter lives in two places:
- PHP side — a Composer package
ephpm/runtime-symfonythat registers itself via Symfony’s runtime resolver. Users opt in by settingAPP_RUNTIMEin their environment orcomposer.json— no code change topublic/index.php. - Rust side — nothing new. The engine primitives
(
\Ephpm\Worker\take_request()etc.) shipped in 3.0 and already power the Octane and WordPress adapters. Nothing Symfony-specific is needed in Rust. All adapters call the same primitives.
The Symfony Runtime Contract
Symfony Runtime drivers implement two interfaces — both small:
| Interface | Methods | What it does |
|---|---|---|
Symfony\Component\Runtime\RuntimeInterface | getRunner($application), getResolver() | Discovers the user’s Kernel (or whatever the entry point returned) and wraps it in a runner |
Symfony\Component\Runtime\RunnerInterface | run(): int | The actual request loop — runs until shutdown, returns an exit code |
There is no equivalent of Octane’s Client — Symfony Runtime hands the
runner the resolved application directly, and the runner calls
Kernel::handle($req) itself. There is no equivalent of Octane’s worker
lifecycle events either; Symfony fires kernel.request / kernel.response /
kernel.terminate through the existing EventDispatcher and the
ResetServicesListener hooks into kernel.terminate to reset state.
Result: most of the work an Octane driver does is delegated to the HttpKernel itself.
SAPI Surface
Already shipped — the engine registers framework-neutral primitives under
\Ephpm\Worker\* (verified in crates/ephpm-php/ephpm_wrapper.c):
\Ephpm\Worker\take_request(): ?\Ephpm\Worker\Envelope
\Ephpm\Worker\send_response(int $status, array $headers, string $body): void
\Ephpm\Worker\send_response_stream(int $status, array $headers, $bodyResource): voidThere is no on_tick() primitive — tick/interval callbacks are planned, not
yet implemented. The adapter builds the Symfony Request from the Envelope
(note: parsedBody() is always null, files() always [], and
cookies()/query() are not url-decoded — the adapter parses/decodes) and
unpacks the Symfony Response into (status, headers, body) for
send_response.
Adapter Code (PHP side)
The complete adapter is roughly the following — three small classes:
namespace Ephpm\Symfony\Runtime;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\HttpKernel\TerminableInterface;
use Symfony\Component\Runtime\GenericRuntime;
use Symfony\Component\Runtime\RunnerInterface;
use Symfony\Component\Runtime\SymfonyRuntime;
final class Runtime extends SymfonyRuntime
{
public function getRunner(?object $application): RunnerInterface
{
if ($application instanceof HttpKernelInterface) {
return new Runner($application);
}
return parent::getRunner($application); // fall back for console etc.
}
}
final class Runner implements RunnerInterface
{
public function __construct(private HttpKernelInterface $kernel) {}
public function run(): int
{
while ($envelope = \Ephpm\Worker\take_request()) {
$request = EnvelopeConverter::toSymfonyRequest($envelope);
$response = $this->kernel->handle($request);
\Ephpm\Worker\send_response(
$response->getStatusCode(),
$response->headers->all(),
$response->getContent() ?: '',
);
if ($this->kernel instanceof TerminableInterface) {
$this->kernel->terminate($request, $response);
// kernel.terminate fires ResetServicesListener,
// which calls reset() on every service tagged kernel.reset.
}
}
return 0;
}
}That’s the whole adapter. Compare against the Octane driver’s Client plus
worker bootstrap plus driver registration — Symfony Runtime is leaner because
the framework was designed for this seam from the start.
User opts in via:
# .env or environment
APP_RUNTIME=Ephpm\Symfony\Runtime\Runtimepublic/index.php is unchanged — Symfony’s Runtime autoloader picks the
class up via composer.json’s extra.runtime.class field too.
What Symfony Already Does (That Octane Has to Do Manually)
| Concern | Octane | Symfony |
|---|---|---|
| Reset container singletons between requests | FlushArrayCache, FlushAuthenticationState, … listeners | kernel.reset tag + ResetServicesListener (fires on kernel.terminate) |
| Reset DB connections | DisconnectFromDatabases listener | Doctrine’s EntityManager is tagged kernel.reset automatically |
| Reset translator | FlushTranslatorState listener | Translator implements ResetInterface |
| Reset session | FlushSessionState listener | Session bag handles it |
| Reset request stack | Per-request scope binding | RequestStack is ResetInterface |
| Reset arbitrary user services | App responsibility (developer adds listeners) | App responsibility (tag with kernel.reset or implement ResetInterface) |
The last row is the migration story for app developers. Most modern Symfony
apps use third-party bundles that already implement ResetInterface. Apps
with custom stateful services need to either implement that interface or tag
the service kernel.reset. We document this clearly; we do not try to
auto-detect or fix it.
What ePHPm Provides That Stock Symfony Doesn’t
Symfony’s stock setup gets you a worker-mode kernel and that’s it. ePHPm adds:
- Built-in HTTP server with TLS/ACME, HTTP/2, static files — no nginx in front.
ephpm-kvexposed asephpm_kv_*PHP functions — gossip-replicated shared cache, available without leaving the binary. Equivalent to whatOctane::tableprovides on the Laravel side, just exposed as functions rather than a facade.- Cluster awareness —
ephpm_kv_*reads/writes replicate via gossip. - In-process MySQL proxy — Doctrine connects to
127.0.0.1:3306and gets pooled, multiplexed connections to the real backend. - Embedded SQLite via litewire — single-file deploys with optional clustering.
A stock runtime/frankenphp-symfony deploy needs an external Caddy, an
external Redis for the cache, and an external nginx-or-equivalent in
production. ePHPm is the whole stack.
Adapter for Symfony Messenger Workers
bin/console messenger:consume is already a long-lived PHP process —
it’s the messenger:consume command running in a loop, not request-driven.
Today it runs under php-cli. Under ePHPm it can run inside the same binary
as the HTTP workers, sharing:
- The same TSRM thread pool — Messenger workers are just another kind of worker thread.
- The same
ephpm-kvinstance — perfect for stamps, dedup keys, rate limits. - The same
ephpm-dbconnection pool — no separate connection budget for workers vs. HTTP. - The same metrics/observability surface — Prometheus picks up worker stats alongside request stats.
This is not part of the minimum Runtime adapter. It’s a Phase-3 add-on
covered under Open Issues. The hook point: runtime/symfony already routes
Symfony\Component\Console\Application through getRunner(), so a
console-aware runner can claim Messenger commands and run them on the
TSRM pool instead of forking a separate php-cli process.
Open Issues
Shared namespace naming — RESOLVED
The engine shipped the primitives under the neutral Ephpm\Worker\*
namespace from the start; the shipped Octane and WordPress adapters consume
it, and the Symfony adapter will too. No rename needed.
getenv() pollution between requests
This is a known issue across all Symfony Runtime adapters in worker
mode. Symfony Runtime reads $_SERVER / $_ENV at boot to populate
$context; subsequent requests can leak env vars set during request handling
into later requests. The mitigation is the standard one used by FrankenPHP
and Swoole adapters: snapshot the env at boot, reset it from the snapshot at
the top of each request loop iteration. Document this as part of the
adapter’s run() method.
Where to publish the package
Three options, ranked:
ephpm/runtime-symfonyunder our own org — fastest to ship, full control, predictable maintenance. Default plan.- Submit
runtime/ephpmto thephp-runtimeorg — natural home (it’s where FrankenPHP, Swoole, ReactPHP, Bref live). Requires their review. Ideal endgame once stable. - Both — publish under our org for early adopters, mirror to
php-runtime/runtime/ephpmonce it stabilizes. Adds some maintenance burden but the adapter is so small it doesn’t matter.
Path: ship as ephpm/runtime-symfony first; submit upstream after Phase 2
stabilizes.
Drupal-on-Symfony reuse
Drupal 11+ uses Symfony’s HttpKernel under the hood. In principle the same
adapter works for Drupal — \Drupal\Core\DrupalKernel is a Symfony Kernel.
In practice Drupal has its own state-management story (caches, plugin
discovery, render cache) that may or may not survive worker mode cleanly.
Out of scope for the initial adapter; revisit once stable.
EventLoop integration
Symfony 7.1+ ships
symfony/scheduler and
symfony/clock
which integrate with Revolt. ePHPm’s tokio runtime
could in principle expose itself as a Revolt driver, allowing Symfony’s
scheduler to drive callbacks via tokio rather than via a userland event
loop. Out of scope for the initial adapter; tracked separately under
Architecture.
Phasing
Phase 1 — Worker mode primitive (prerequisite) — SHIPPED
Shipped in ePHPm 3.0. See PHP Worker Mode;
the reference examples/worker/worker.php loop serves responses with zero
per-request bootstrap, and the Octane and WordPress adapters run on it in
production.
Phase 2 — Minimal Symfony Runtime adapter
ephpm/runtime-symfony Composer package. The three classes shown above plus
composer.json, extra.runtime.class registration, and an end-to-end test
against a stock symfony/skeleton app.
Exit criteria: APP_RUNTIME=Ephpm\Symfony\Runtime\Runtime in .env
makes a stock Symfony app serve requests through the ephpm worker loop, with
correct kernel.reset behavior.
Phase 3 — Messenger worker integration
Detect Symfony\Component\Console\Application in getRunner(); for
messenger:consume commands, run the consumer loop on the ephpm TSRM pool
rather than as a separate CLI process.
Exit criteria: messenger:consume async --time-limit=… runs inside the
ephpm process and shares the in-process KV / DB pool.
Phase 4 — Upstream to php-runtime org
Submit the package to the php-runtime/runtime
monorepo as runtime/ephpm. Coordinate with maintainers.
Phase 5 — Optional: Revolt event-loop driver
Expose ephpm’s tokio runtime as a Revolt driver so Symfony Scheduler / Clock work without a userland event loop. Separate effort, separate PR.
Out of Scope
- Drupal-specific shims. Drupal uses Symfony’s kernel but has its own state quirks. Phase 5+ at the earliest.
- Twig template compilation cache invalidation. Symfony already handles
this via
kernel.reseton the Twig environment in dev mode. Production ships compiled templates; no special handling needed. bin/consolefor arbitrary commands. Worker mode for HTTP only. One-shot console commands run through the existing PHP CLI path.runtime/symfony-runtime’s--no-debug/ debug toggles. Symfony handles these itself viaAPP_DEBUG; we don’t intercept.
References
- Symfony Runtime Component — official docs
php-runtime/runtime— community org with FrankenPHP, Swoole, RoadRunner, ReactPHP, Bref adapters- Symfony
kernel.resettag — service reset semantics - ePHPm Laravel Octane driver — shipped adapter on the same engine
- ePHPm Architecture: PHP Worker Mode — the shipped engine