When I moved CaRMen's Playwright tests into GitHub Actions, every login test failed. Partway through each test, the login page redirected to the first-run installer, as if the database were empty. It wasn't. The PHP backend wasn't looking at the database the job had set up at all, because it never saw the environment variables that pointed to it.
The setup
The end-to-end job starts a Postgres service container, installs the Symfony backend, loads fixtures, and serves the backend with PHP's built-in web server, php -S 127.0.0.1:8000 -t public/, for the React frontend and Playwright to talk to. The configuration came from job-level environment variables in the workflow:
APP_ENV: devDATABASE_URL: postgresql://crm:crm@127.0.0.1:5432/crm?...
The backend's .env was a copy of .env.test, which has APP_ENV=test and a database host called db, the name of the database container in the local compose setup. There's no host called db in CI. Every API call failed, the installer's status endpoint answered "not installed", and the frontend did what it's built to do when the app isn't installed: it sent the login page to /installer.
Where the environment variables went
PHP was installed with the setup-php action, and its README says that by default it uses the production php.ini. PHP's own php.ini-production sets:
variables_order = "GPCS"
That directive controls which superglobals PHP fills in, from the letters E, G, P, C and S: environment, GET, POST, cookie and server. GPCS leaves out E, so $_ENV stays empty. The PHP manual adds that in the CGI and FastCGI SAPIs, $_SERVER also receives the environment anyway.
The built-in web server isn't one of those. CI runs PHP 8.4; I tested locally with PHP 8.5, using a script that reports where an environment variable is visible, and APP_X=1 set when starting the server:
php -SwithGPCS: not in$_ENV, not in$_SERVER, butgetenv()finds itphp -SwithEGPCS: in$_ENV, still not in$_SERVER- the plain CLI with
GPCS, for comparison: in$_SERVER
So the same php.ini that lets a console command see the environment through $_SERVER hides it from a request served by php -S.
Why Symfony picked .env instead
The variables were still reachable through getenv(). Symfony's Dotenv component doesn't look there. When it loads .env, it decides whether a variable is already set by checking $_ENV and $_SERVER only; a comment in its source says it doesn't check with getenv() because of thread-safety issues. With both superglobals empty, every variable counted as unset, so Dotenv loaded the values from .env: the test environment and the database host that didn't exist.
The fix
I could have changed the PHP configuration for the job. I went the other way and stopped relying on environment variables for this server at all: the step that installs the backend now writes the values into .env itself, with sed replacing the two lines in a copy of .env.test:
sed -e 's/^APP_ENV=.*/APP_ENV=dev/' \-e 's|^DATABASE_URL=.*|DATABASE_URL="postgresql://crm:crm@127.0.0.1:5432/crm?..."|' \.env.test > .env
A comment above it says why, so the next person who sees job-level env: as the obvious place doesn't move it back. APP_ENV is dev rather than test on purpose too: the specs log in as the users the dev fixtures create.
The second change is about failing in the right place. The step that starts the backend used to check only that the server answered. It now reads the installer status and fails the step, printing the backend's log, unless the response contains "installed":true. The comment says it plainly: fail here rather than as 16 confusing UI timeouts. A broken backend is now one clear failure in the step that started it, not a page of browser test errors that all look like frontend problems.
The suite this job runs is the one that found a login bug on its first real run. Getting it to run in CI found one more: not in the app, but in the assumption that a job's env: block reaches every PHP process the job starts.
Leave a comment
Comments are reviewed before they appear.