- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You have a Symfony app running on PHP-FPM. It works. Then someone benchmarks a RoadRunner setup and the throughput numbers make your CTO ask why you aren't on it already. So you open public/index.php to migrate, and on a Symfony 5.2 codebase you find a wall of bootstrap: build the kernel, create a Request from globals, call handle(), send() the response, terminate(). All of it hardwired to one way of being served.
That file is the problem. Your kernel handles a request the same way no matter who calls it, but the entrypoint that feeds it a request is glued to PHP-FPM's request-per-process model. Move to a long-running worker and you rewrite the entrypoint by hand, including the event loop.
The Runtime component, stable since Symfony 5.3, exists to delete that glue. Here's how it works and why it matters for anyone who cares where boundaries sit.
The front controller Runtime replaced
On a pre-5.3 app, public/index.php looked like this:
<?php
use App\Kernel;
use Symfony\Component\ErrorHandler\Debug;
use Symfony\Component\HttpFoundation\Request;
require dirname(__DIR__).'/config/bootstrap.php';
if ($_SERVER['APP_DEBUG']) {
umask(0000);
Debug::enable();
}
$kernel = new Kernel(
$_SERVER['APP_ENV'],
(bool) $_SERVER['APP_DEBUG'],
);
$request = Request::createFromGlobals();
$response = $kernel->handle($request);
$response->send();
$kernel->terminate($request, $response);
Read what it hardcodes. Request::createFromGlobals() assumes $_SERVER, $_GET, and $_POST exist, which is a PHP-FPM/mod_php assumption. $response->send() writes straight to the SAPI output buffer. The whole file assumes one process serves one request and dies. A RoadRunner worker does none of that. It boots once and loops over many requests. This entrypoint cannot be reused there.
What the component actually does
Since Symfony 5.3, the front controller is a returned callable:
<?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'],
);
};
No handle(), no send(), no globals. You return a kernel and stop. The $context array is $_SERVER plus $_ENV, handed to you instead of read by you. That inversion is the whole point.
The magic lives in vendor/autoload_runtime.php, a file generated by the symfony/runtime Composer plugin. It does three things:
- Instantiates a runtime object (which class, you choose — more below).
- Resolves the callable your
index.phpreturned, injecting arguments by type. - Runs whatever that callable produced and exits with its status code.
The runtime knows how to run several return types. Give it a HttpKernelInterface and it creates the Request, calls handle(), sends the response, and terminates. Give it a Response and it sends that. Give it a console Application, a PSR-15 RequestHandlerInterface, or a Command, and it runs each correctly. Your entrypoint states what to run; the runtime owns how.
The contract underneath
Every runtime implements one small interface:
<?php
namespace Symfony\Component\Runtime;
interface RuntimeInterface
{
public function getResolver(
callable $callable,
): ResolverInterface;
public function getRunner(
?object $application,
): RunnerInterface;
}
ResolverInterface::resolve() returns [callable, arguments]. The resolver reads your closure's typed parameters and fills them: ask for array $context and you get the env, ask for Request and you get one built from globals. RunnerInterface::run() returns an integer exit code and contains all the SAPI-specific work — sending the response on FPM, or spinning an event loop on a worker SAPI.
Two interfaces, both tiny. That's the seam the whole abstraction hangs on. Your kernel sits behind it and never learns which implementation is on the other side.
The console shares the exact same seam
bin/console is the same shape as index.php, which is the tell that this is a general entrypoint abstraction, not an HTTP trick:
#!/usr/bin/env php
<?php
use App\Kernel;
use Symfony\Bundle\FrameworkBundle\Console\Application;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return function (array $context) {
$kernel = new Kernel(
$context['APP_ENV'],
(bool) $context['APP_DEBUG'],
);
return new Application($kernel);
};
Same autoload_runtime.php, same context injection, different return type. The runtime sees a console Application and runs it against argv instead of a request. HTTP and CLI stopped being two bootstrap files with duplicated setup and became two return values from one mechanism.
Switching servers is a config line
Here's where the decoupling pays off. You pick the runtime with the APP_RUNTIME env var or a composer.json entry, not by editing index.php:
{
"require": {
"runtime/frankenphp-symfony": "^0.2"
},
"extra": {
"runtime": {
"class": "Runtime\\FrankenPhpSymfony\\Runtime"
}
}
}
Swap that class for Runtime\RoadRunnerSymfonyNyholm\Runtime (from runtime/roadrunner-symfony-nyholm) and the same kernel now runs inside a RoadRunner worker loop. There are community runtimes for FrankenPHP, RoadRunner, Swoole, ReactPHP, and PSR-7/Nyholm, plus the default SymfonyRuntime for FPM and CLI. Bref ships its own for AWS Lambda.
Each runtime hides a different serving model. RoadRunner and Swoole boot the kernel once and reuse it across thousands of requests. FrankenPHP embeds PHP in a Go server with a worker mode. FPM forks a process per request. Your index.php returns the same closure for all of them. The loop, the request source, the response sink: none of it leaks into your code.
Prove it with a custom runtime
You rarely write one, but writing a thin wrapper shows how closed the seam is. Extend SymfonyRuntime and decorate its runner:
<?php
namespace App\Runtime;
use Symfony\Component\Runtime\RunnerInterface;
use Symfony\Component\Runtime\SymfonyRuntime;
final class TimingRuntime extends SymfonyRuntime
{
public function getRunner(
?object $application,
): RunnerInterface {
$inner = parent::getRunner($application);
return new class($inner) implements RunnerInterface {
public function __construct(
private RunnerInterface $inner,
) {}
public function run(): int
{
$t = hrtime(true);
$code = $this->inner->run();
error_log(sprintf(
'ran in %.1f ms',
(hrtime(true) - $t) / 1e6,
));
return $code;
}
};
}
}
Point composer.json's extra.runtime.class at App\Runtime\TimingRuntime and every entrypoint, HTTP and CLI, gets timed. The kernel changed by zero lines. That is the test of a real boundary: you altered how the app is served without the served code noticing.
Why the kernel should not know how it is served
The FPM index.php from earlier read $_SERVER directly, built the Request itself, and sent bytes to the SAPI. Those are transport details. A kernel that reaches for them has an opinion about its own deployment baked into application code, and that opinion turns into a rewrite the day the deployment changes.
The Runtime component pushes those details to the outermost ring. The kernel receives a Request and returns a Response. Whether that request came from FPM globals, a RoadRunner PSR-7 message, or a Lambda event is a decision made in composer.json, resolved by generated bootstrap code you never touch. Your domain and application layers sit two rings further in and never see even the kernel's request handling, let alone the SAPI.
This is the same instinct that keeps your database driver behind a repository interface. The mechanism that connects your app to the outside world belongs at the edge, swappable, with the core unaware of which mechanism is currently plugged in.
Where does the worst version of this coupling live in your app today? Usually it is not the entrypoint anymore. It is one layer deeper, where a controller or service quietly knows something about how it got called.
If this was useful
The Runtime component is a clean example of a broader habit: keep the concern that ties your app to a specific runtime out at the boundary, and let the core stay ignorant of it. The front controller is the easy case because Symfony solved it for you. The hard cases are the ones you own: the service that assumes an HTTP context, the domain object that knows about the ORM, the use case that reaches for the framework. Decoupled PHP is about drawing those lines on purpose, so the parts of your system that outlive any framework or server actually get to.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)