From 5a9899295c01a80a6089346bfbab33e890abe28f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 2 Jan 2026 21:56:34 +0100 Subject: [PATCH] Support custom runner with new `X_EXPERIMENTAL_RUNNER` variable --- docs/best-practices/controllers.md | 33 ++++++++------- src/App.php | 7 +++- src/Container.php | 24 +++++++++-- tests/AppTest.php | 16 ++++++++ tests/ContainerTest.php | 64 ++++++++++++++++++++++++++++-- 5 files changed, 123 insertions(+), 21 deletions(-) diff --git a/docs/best-practices/controllers.md b/docs/best-practices/controllers.md index 7f725f4..be304f9 100644 --- a/docs/best-practices/controllers.md +++ b/docs/best-practices/controllers.md @@ -381,25 +381,30 @@ all uppercase in any factory function like this: // … ``` -=== "Built-in environment variables" +Besides defining custom environment variables, you may also override built-in +environment variables used by X itself like this: - ```php title="public/index.php" - '0.0.0.0:8081' - // 'X_LISTEN' => fn(int|string $PORT = 8080) => '0.0.0.0:' . $PORT - 'X_LISTEN' => fn(string $X_LISTEN = '127.0.0.1:8080') => $X_LISTEN - ]); +$container = new FrameworkX\Container([ + // Framework X also uses environment variables internally. + // You may explicitly configure this built-in functionality like this: + // 'X_LISTEN' => '0.0.0.0:8081' + // 'X_LISTEN' => fn(int|string $PORT = 8080) => '0.0.0.0:' . $PORT + 'X_LISTEN' => fn(string $X_LISTEN = '127.0.0.1:8080') => $X_LISTEN, + + // 'X_EXPERIMENTAL_RUNNER' => AcmeRunner::class + // 'X_EXPERIMENTAL_RUNNER' => fn(bool|string $ACME = false): ?string => $ACME ? AcmeRunner::class : null + 'X_EXPERIMENTAL_RUNNER' => fn(?string $X_EXPERIMENTAL_RUNNER = null): ?string => $X_EXPERIMENTAL_RUNNER, +]); - $app = new FrameworkX\App($container); +$app = new FrameworkX\App($container); - // … - ``` +// … +``` > ℹ️ **Passing environment variables** > diff --git a/src/App.php b/src/App.php index 4fcb4c8..808e3d9 100644 --- a/src/App.php +++ b/src/App.php @@ -22,7 +22,7 @@ class App /** @var RouteHandler */ private $router; - /** @var HttpServerRunner|SapiRunner */ + /** @var HttpServerRunner|SapiRunner|callable(callable(ServerRequestInterface):(ResponseInterface|PromiseInterface)):void */ private $runner; /** @@ -253,8 +253,13 @@ public function redirect(string $route, string $target, int $code = Response::ST * This is particularly useful because it allows you to run the exact same * application code in any environment. * + * For more advanced use cases, this behavior can be overridden by setting + * the `X_EXPERIMENTAL_RUNNER` environment variable to the desired runner + * class name ({@see Container::getRunner()}). + * * @see HttpServerRunner::__invoke() * @see SapiRunner::__invoke() + * @see Container::getRunner() */ public function run(): void { diff --git a/src/Container.php b/src/Container.php index da97d50..4f076ae 100644 --- a/src/Container.php +++ b/src/Container.php @@ -181,14 +181,32 @@ public function getObject(string $class) /*: object (PHP 7.2+) */ /** * [Internal] Get the app runner appropriate for this environment from container * - * @return HttpServerRunner|SapiRunner + * By default, this method returns an instance of `HttpServerRunner` when + * running in CLI mode, and an instance of `SapiRunner` when running in a + * traditional web server environment. + * + * For more advanced use cases, this behavior can be overridden by setting + * the `X_EXPERIMENTAL_RUNNER` environment variable to the desired runner + * class name. The specified class must be invokable with the main request + * handler signature. Note that this is an experimental feature and the API + * may be subject to change in future releases. + * + * @return HttpServerRunner|SapiRunner|callable(callable(ServerRequestInterface):(\Psr\Http\Message\ResponseInterface|\React\Promise\PromiseInterface<\Psr\Http\Message\ResponseInterface>)):void * @throws \TypeError if container config or factory returns an unexpected type * @throws \Throwable if container factory function throws unexpected exception * @internal + * @see App::run() */ - public function getRunner() /*: HttpServerRunner|SapiRunner (PHP 8.0+) */ + public function getRunner(): callable /*: HttpServerRunner|SapiRunner|callable (PHP 8.0+) */ { - return $this->getObject(\PHP_SAPI === 'cli' ? HttpServerRunner::class : SapiRunner::class); + // @phpstan-ignore-next-line `getObject()` already performs type checks if `getEnv()` returns an invalid class + $runner = $this->getObject($this->getEnv('X_EXPERIMENTAL_RUNNER') ?? (\PHP_SAPI === 'cli' ? HttpServerRunner::class : SapiRunner::class)); + if (!\is_callable($runner)) { + throw new \TypeError( + 'Return value of ' . __METHOD__ . '() must be of type callable, ' . $this->gettype($runner) . ' returned' + ); + } + return $runner; } /** diff --git a/tests/AppTest.php b/tests/AppTest.php index 2efac15..2b8eb52 100644 --- a/tests/AppTest.php +++ b/tests/AppTest.php @@ -933,6 +933,22 @@ public function testRunWillInvokeRunnerFromContainer(): void $app->run(); } + public function testRunWillInvokeCustomRunnerFromContainerEnvironmentVariable(): void + { + $runner = $this->createMock(HttpServerRunner::class); + $runner->expects($this->once())->method('__invoke'); + + $container = new Container([ + 'X_EXPERIMENTAL_RUNNER' => get_class($runner), + get_class($runner) => $runner, + HttpServerRunner::class => function () { throw new \BadFunctionCallException('Should not be called'); } + ]); + + $app = new App($container); + + $app->run(); + } + public function testGetMethodAddsGetRouteOnRouter(): void { $router = $this->createMock(RouteHandler::class); diff --git a/tests/ContainerTest.php b/tests/ContainerTest.php index da0374c..dd6b97b 100644 --- a/tests/ContainerTest.php +++ b/tests/ContainerTest.php @@ -2833,6 +2833,7 @@ public function testGetRunnerReturnsDefaultHttpServerRunnerInstance(): void $this->assertInstanceOf(HttpServerRunner::class, $runner); + assert($runner instanceof HttpServerRunner); $ref = new \ReflectionProperty($runner, 'listenAddress'); if (PHP_VERSION_ID < 80100) { $ref->setAccessible(true); @@ -2852,6 +2853,7 @@ public function testGetRunnerReturnsDefaultHttpServerRunnerInstanceWithCustomLis $this->assertInstanceOf(HttpServerRunner::class, $runner); + assert($runner instanceof HttpServerRunner); $ref = new \ReflectionProperty($runner, 'listenAddress'); if (PHP_VERSION_ID < 80100) { $ref->setAccessible(true); @@ -2888,7 +2890,10 @@ public function testGetRunnerReturnsHttpServerRunnerInstanceFromPsrContainer(): $runner = new HttpServerRunner(new LogStreamHandler('php://output'), null); $psr = $this->createMock(ContainerInterface::class); - $psr->expects($this->once())->method('has')->with(HttpServerRunner::class)->willReturn(true); + $psr->expects($this->exactly(2))->method('has')->willReturnMap([ + ['X_EXPERIMENTAL_RUNNER', false], + [HttpServerRunner::class, true], + ]); $psr->expects($this->once())->method('get')->with(HttpServerRunner::class)->willReturn($runner); assert($psr instanceof ContainerInterface); @@ -2902,7 +2907,8 @@ public function testGetRunnerReturnsHttpServerRunnerInstanceFromPsrContainer(): public function testGetRunnerReturnsDefaultHttpServerRunnerInstanceWithDefaultListenAddressIfPsrContainerHasNoEntry(): void { $psr = $this->createMock(ContainerInterface::class); - $psr->expects($this->exactly(2))->method('has')->willReturnMap([ + $psr->expects($this->exactly(3))->method('has')->willReturnMap([ + ['X_EXPERIMENTAL_RUNNER', false], [HttpServerRunner::class, false], ['X_LISTEN', false], ]); @@ -2915,6 +2921,7 @@ public function testGetRunnerReturnsDefaultHttpServerRunnerInstanceWithDefaultLi $this->assertInstanceOf(HttpServerRunner::class, $runner); + assert($runner instanceof HttpServerRunner); $ref = new \ReflectionProperty($runner, 'listenAddress'); if (PHP_VERSION_ID < 80100) { $ref->setAccessible(true); @@ -2927,7 +2934,8 @@ public function testGetRunnerReturnsDefaultHttpServerRunnerInstanceWithDefaultLi public function testGetRunnerReturnsDefaultHttpServerRunnerInstanceWithCustomListenAddressIfPsrContainerHasNoEntryButCustomListenAddress(): void { $psr = $this->createMock(ContainerInterface::class); - $psr->expects($this->exactly(2))->method('has')->willReturnMap([ + $psr->expects($this->exactly(3))->method('has')->willReturnMap([ + ['X_EXPERIMENTAL_RUNNER', false], [HttpServerRunner::class, false], ['X_LISTEN', true], ]); @@ -2940,6 +2948,7 @@ public function testGetRunnerReturnsDefaultHttpServerRunnerInstanceWithCustomLis $this->assertInstanceOf(HttpServerRunner::class, $runner); + assert($runner instanceof HttpServerRunner); $ref = new \ReflectionProperty($runner, 'listenAddress'); if (PHP_VERSION_ID < 80100) { $ref->setAccessible(true); @@ -2949,6 +2958,55 @@ public function testGetRunnerReturnsDefaultHttpServerRunnerInstanceWithCustomLis $this->assertEquals('127.0.0.1:8081', $listenAddress); } + public function testGetRunnerReturnsCustomRunnerInstanceFromEnvironmentVariable(): void + { + $runner = new class { + public function __invoke(): void {} + }; + + $container = new Container([ + 'X_EXPERIMENTAL_RUNNER' => get_class($runner), + get_class($runner) => $runner + ]); + + $ret = $container->getRunner(); + + $this->assertSame($runner, $ret); + } + + public function testGetRunnerThrowsForInvalidEnvironmentVariableType(): void + { + $container = new Container([ + 'X_EXPERIMENTAL_RUNNER' => 42 + ]); + + $this->expectException(\TypeError::class); + $this->expectExceptionMessage('Return value of ' . Container::class . '::getEnv() for $X_EXPERIMENTAL_RUNNER must be of type string|null, int returned'); + $container->getRunner(); + } + + public function testGetRunnerThrowsForUnknownClassNameInEnvironmentVariable(): void + { + $container = new Container([ + 'X_EXPERIMENTAL_RUNNER' => 'UnknownClass' + ]); + + $this->expectException(\Error::class); + $this->expectExceptionMessage('Class UnknownClass not found'); + $container->getRunner(); + } + + public function testGetRunnerThrowsForClassNotCallableInEnvironmentVariable(): void + { + $container = new Container([ + 'X_EXPERIMENTAL_RUNNER' => \stdClass::class + ]); + + $this->expectException(\TypeError::class); + $this->expectExceptionMessage('Return value of ' . Container::class . '::getRunner() must be of type callable, stdClass returned'); + $container->getRunner(); + } + public function testInvokeContainerAsMiddlewareReturnsFromNextRequestHandler(): void { $request = new ServerRequest('GET', 'http://example.com/');