In worker mode, the variables Dotenv::bootEnv() writes at worker boot are not there when the DI container resolves %env(...)%, so a Symfony app throws EnvNotFoundException on every request — including the first one served by a worker.
Traditional mode is unaffected (dotenv is booted per request there).
Because of #2 the failure is easy to miss: it surfaces as a 200 carrying a 22-byte Internal Server Error body from AbstractHttpRunner::emitFallback500().
Environment
|
|
oxphp/runtime |
v0.2.0 |
| OxPHP server |
0.10.0 (ghcr.io/oxphp/oxphp:0.10.0) |
oxphp_sapi ext |
0.1.0 |
| PHP |
8.5.9 ZTS, Alpine |
| App |
Symfony 8.1, extra.runtime: {"class": "OxPHP\\Runtime\\Runtime", "disable_dotenv": false} |
| Server config |
WORKER_MODE_ENABLED=true, ENTRY_FILE=index.php, PHP_WORKERS=1 |
Reproduced with SUPERGLOBALS_ENABLED both true and false.
What the app sees
EnvNotFoundException: Environment variable not found: "OPENROUTER_MODEL".
in vendor/symfony/dependency-injection/EnvVarProcessor.php:224
#0 vendor/symfony/dependency-injection/Container.php(383): EnvVarProcessor->getEnv('string', 'OPENROUTER_MODE…', Closure)
#1 var/cache/dev/…/getAi_TraceableAgent_DocsService.php(53): Container->getEnv('string:OPENROUT…')
OPENROUTER_MODEL and DATABASE_URL both come from the project's committed .env.
Instrumented run
I patched Internal\Runner\HttpKernelRunner::handleRequest() and mounted it into the container:
protected function handleRequest(OxRequest $ox): void
{
\error_log(\sprintf('START env=%d server=%d OPENROUTER_MODEL: ENV=%s SERVER=%s getenv=%s DOTENV_VARS=%s',
\count($_ENV), \count($_SERVER),
\var_export($_ENV['OPENROUTER_MODEL'] ?? null, true),
\var_export($_SERVER['OPENROUTER_MODEL'] ?? null, true),
\var_export(\getenv('OPENROUTER_MODEL'), true),
\var_export(isset($_SERVER['SYMFONY_DOTENV_VARS']) || isset($_ENV['SYMFONY_DOTENV_VARS']), true)));
$request = $this->builder->build($ox);
\error_log(\sprintf('AFTER-BUILD env=%d OPENROUTER_MODEL=%s',
\count($_ENV), \var_export($_ENV['OPENROUTER_MODEL'] ?? null, true)));
$response = $this->kernel->handle($request); // <- throws
// …plus an END probe after terminate(), which never fires
}
Two requests, single worker:
START env=39 server=45 OPENROUTER_MODEL: ENV='openai/gpt-4o-mini' SERVER=NULL getenv=false DOTENV_VARS=true
AFTER-BUILD env=39 OPENROUTER_MODEL='openai/gpt-4o-mini'
<-- kernel->handle() throws EnvNotFoundException; END probe never reached
START env=26 server=45 OPENROUTER_MODEL: ENV=NULL SERVER=NULL getenv=false DOTENV_VARS=false
AFTER-BUILD env=26 OPENROUTER_MODEL=NULL
So there are two distinct losses:
$_SERVER is gone from the very first request. The dotenv copy in $_SERVER is replaced by request data before the handler runs (SERVER=NULL, server=45). Survivable on its own, because EnvVarProcessor reads $_ENV[$name] ?? $_SERVER[$name] ?? getenv($name).
$_ENV loses the dotenv keys too. On request 1 they are still present right up to handle() (39 entries, SYMFONY_DOTENV_VARS set) and the lookup inside handle() still fails; by request 2 they are already gone before the handler starts — $_ENV is back to the 26 process-environment entries and SYMFONY_DOTENV_VARS is gone with them. I could not pin the exact instant inside handle() where request 1 loses them.
getenv() returns false throughout, since Dotenv runs with usePutenv(false) by default.
Framework-free data point
The same setup without a Symfony kernel — index.php returning an anonymous HttpKernelInterface that just dumps the arrays, .env containing MY_VAR=hello, extra.runtime = {"class": "OxPHP\\Runtime\\Runtime", "disable_dotenv": false}:
return new JsonResponse([
'from_SERVER' => $_SERVER['MY_VAR'] ?? null,
'from_ENV' => $_ENV['MY_VAR'] ?? null,
'from_getenv' => getenv('MY_VAR') ?: null,
'SERVER_count' => count($_SERVER),
'ENV_count' => count($_ENV),
]);
worker mode, three consecutive requests:
{"from_SERVER":null,"from_ENV":"hello","from_getenv":null,"SERVER_count":44,"ENV_count":29}
{"from_SERVER":null,"from_ENV":"hello","from_getenv":null,"SERVER_count":44,"ENV_count":29}
{"from_SERVER":null,"from_ENV":"hello","from_getenv":null,"SERVER_count":44,"ENV_count":29}
traditional mode, same requests:
{"from_SERVER":"hello","from_ENV":"hello","from_getenv":null,"SERVER_count":49,"ENV_count":27}
So loss (1) reproduces standalone and is stable, while loss (2) only shows up once a Symfony kernel is handling the request — which is why the minimal script keeps working and a real app does not.
Why this matters for the adapter
SymfonyRuntime boots dotenv exactly once, in the constructor, and the adapter then keeps that process alive across requests. Anything the boot wrote into the superglobals is therefore boot-time state living in arrays that the worker soft-reset owns. A one-shot request never notices; a worker does, immediately.
Possible directions, none of which I have tested:
- Snapshot the env the runtime booted with and re-apply it into
$_ENV/$_SERVER at the top of each worker iteration (before handleRequest()), so per-request repopulation cannot erase it.
- Or boot dotenv with
use_putenv: true, since EnvVarProcessor falls back to getenv() — though putenv() under ZTS deserves its own thought.
Happy to test a patch against the real app (Symfony 8.1 site, ~20 routes, six locales) if that helps.
Related: #2 — the 200 masking every failure above.
In worker mode, the variables
Dotenv::bootEnv()writes at worker boot are not there when the DI container resolves%env(...)%, so a Symfony app throwsEnvNotFoundExceptionon every request — including the first one served by a worker.Traditional mode is unaffected (dotenv is booted per request there).
Because of #2 the failure is easy to miss: it surfaces as a
200carrying a 22-byteInternal Server Errorbody fromAbstractHttpRunner::emitFallback500().Environment
oxphp/runtimeghcr.io/oxphp/oxphp:0.10.0)oxphp_sapiextextra.runtime:{"class": "OxPHP\\Runtime\\Runtime", "disable_dotenv": false}WORKER_MODE_ENABLED=true,ENTRY_FILE=index.php,PHP_WORKERS=1Reproduced with
SUPERGLOBALS_ENABLEDbothtrueandfalse.What the app sees
OPENROUTER_MODELandDATABASE_URLboth come from the project's committed.env.Instrumented run
I patched
Internal\Runner\HttpKernelRunner::handleRequest()and mounted it into the container:Two requests, single worker:
So there are two distinct losses:
$_SERVERis gone from the very first request. The dotenv copy in$_SERVERis replaced by request data before the handler runs (SERVER=NULL,server=45). Survivable on its own, becauseEnvVarProcessorreads$_ENV[$name] ?? $_SERVER[$name] ?? getenv($name).$_ENVloses the dotenv keys too. On request 1 they are still present right up tohandle()(39 entries,SYMFONY_DOTENV_VARSset) and the lookup insidehandle()still fails; by request 2 they are already gone before the handler starts —$_ENVis back to the 26 process-environment entries andSYMFONY_DOTENV_VARSis gone with them. I could not pin the exact instant insidehandle()where request 1 loses them.getenv()returnsfalsethroughout, sinceDotenvruns withusePutenv(false)by default.Framework-free data point
The same setup without a Symfony kernel —
index.phpreturning an anonymousHttpKernelInterfacethat just dumps the arrays,.envcontainingMY_VAR=hello,extra.runtime={"class": "OxPHP\\Runtime\\Runtime", "disable_dotenv": false}:worker mode, three consecutive requests:
{"from_SERVER":null,"from_ENV":"hello","from_getenv":null,"SERVER_count":44,"ENV_count":29} {"from_SERVER":null,"from_ENV":"hello","from_getenv":null,"SERVER_count":44,"ENV_count":29} {"from_SERVER":null,"from_ENV":"hello","from_getenv":null,"SERVER_count":44,"ENV_count":29}traditional mode, same requests:
{"from_SERVER":"hello","from_ENV":"hello","from_getenv":null,"SERVER_count":49,"ENV_count":27}So loss (1) reproduces standalone and is stable, while loss (2) only shows up once a Symfony kernel is handling the request — which is why the minimal script keeps working and a real app does not.
Why this matters for the adapter
SymfonyRuntimeboots dotenv exactly once, in the constructor, and the adapter then keeps that process alive across requests. Anything the boot wrote into the superglobals is therefore boot-time state living in arrays that the worker soft-reset owns. A one-shot request never notices; a worker does, immediately.Possible directions, none of which I have tested:
$_ENV/$_SERVERat the top of each worker iteration (beforehandleRequest()), so per-request repopulation cannot erase it.use_putenv: true, sinceEnvVarProcessorfalls back togetenv()— thoughputenv()under ZTS deserves its own thought.Happy to test a patch against the real app (Symfony 8.1 site, ~20 routes, six locales) if that helps.
Related: #2 — the
200masking every failure above.