RefreshDatabase wiped our dev database: container env beat phpunit.xml
Running the test suite inside our app container dropped every table in the development database. Docker Compose injected DB_DATABASE as a real environment variable, and phpunit.xml's env entries do not override existing variables. Here is the full precedence chain and three layers of defence.
A developer on our team ran the PHP test suite inside the application container before lunch,
went back to the browser, and got a login page that rejected every account. The development
database, seeded with three months of realistic fixtures that several people relied on, was
empty except for the migrations table and a few rows created by test factories.
The test run itself had been green. The only clue in its output was how long the first test took,
about nine seconds, which is what a full migrate:fresh against MySQL costs on that machine.
We ran a single throwaway test that printed the connection it used:
public function test_which_database(): void
{
fwrite(STDERR, config('app.env') . ' / ' . config('database.connections.mysql.database') . PHP_EOL);
$this->assertTrue(true);
}
local / app
The tests had run with APP_ENV=local against the development database app, even though
phpunit.xml clearly said otherwise:
<php>
<env name="APP_ENV" value="testing"/>
<env name="DB_DATABASE" value="app_testing"/>
</php>
Reproducing it
The difference was where the tests ran. On the host, with no relevant variables exported, the same
test printed testing / app_testing. Inside the container it printed local / app every time:
docker compose exec app vendor/bin/phpunit --filter test_which_database
The container got its variables from the Compose file:
services:
app:
build: .
env_file: .env # APP_ENV=local, DB_DATABASE=app, DB_HOST=mysql, ...
docker compose exec app printenv DB_DATABASE
# app
What we ruled out
- A cached configuration. A
bootstrap/cache/config.phpfile makes Laravel ignore environment variables entirely, and it is a common cause of this exact symptom. The file did not exist. - A
.env.testingfile. Laravel loads it when the environment istesting, but the environment never becametesting, and dotenv files do not overwrite variables that already exist anyway. - A rogue test calling
migrate:freshdirectly. The only caller was theRefreshDatabasetrait, used by 140 feature tests.
Root cause
env_file in Docker Compose does not create a file in the container; it sets real process
environment variables. PHPUnit's <env> element deliberately does not overwrite an environment
variable that already exists, unless force="true" is set. And even with force, Laravel reads
$_SERVER before $_ENV and getenv(), and PHP fills $_SERVER with the process environment at
startup, so the container's DB_DATABASE=app still won. RefreshDatabase then did its job
perfectly, against the wrong database.
What is happening under the hood
Three independent mechanisms decide which value Laravel sees for DB_DATABASE:
- PHP at startup. The CLI copies the process environment into
$_SERVER. Ifvariables_ordercontainsE(the built-in defaultEGPCS, used when nophp.inisets it, as in the official Docker images unless you copy one in), it also fills$_ENV. - PHPUnit's
<php>section. For<env>, PHPUnit callsputenv()only ifgetenv()returns false, and sets$_ENV[name]only if it is not already set.force="true"makes both unconditional. For<server>, PHPUnit assigns$_SERVER[name]directly. The schema accepts aforceattribute on<server>too, but in PHPUnit 11 assignment there is already unconditional. - Laravel's environment repository.
Env::get()uses phpdotenv with the server-variable adapter first, then the$_ENVadapter, thengetenv(). The first one that has the key wins.
Put together:
phpunit.xml entries |
$_SERVER |
$_ENV |
getenv() |
Laravel sees |
|---|---|---|---|---|
<env> only |
app |
app |
app |
app (dev DB) |
<env force="true"> only |
app |
app_testing |
app_testing |
app (dev DB) |
<env force="true"> and <server> |
app_testing |
app_testing |
app_testing |
app_testing |
On a host without those variables, the <env> defaults simply fill the gaps, which is why the
configuration looked correct for years.
Environment
| Component | Version / value |
|---|---|
| Framework | Laravel 12, PHP 8.3 (official php:8.3-fpm image, no custom variables_order) |
| Test runner | PHPUnit 11.5 |
| Database | MySQL 8.4 container, dev schema app |
| Orchestration | Docker Compose with env_file: .env |
| Test trait | Illuminate\Foundation\Testing\RefreshDatabase |
Minimal reproduction
# Any variable already present in the process environment wins over a plain <env>.
DB_DATABASE=app vendor/bin/phpunit --filter test_which_database
# local / app (or whatever APP_ENV your shell has)
No Docker is needed: exporting the variable in your shell, a CI job definition or a
.bashrc produces the same result.
Who is affected
Any PHPUnit setup that relies on <env> defaults while the process environment already defines the
same keys: Laravel Sail and custom Compose stacks with env_file or environment:, CI jobs that
export production-like variables, and developers with variables in their shell profile. Frameworks
that read $_SERVER first are affected even with force="true". The damage depends on what the
tests do; with RefreshDatabase or DatabaseMigrations it is a full schema wipe.
We added three independent layers, so that any single mistake can no longer reach a real database.
The fix
1. Force the values in phpunit.xml, for both $_ENV/getenv() and $_SERVER:
<php>
<!-- Container env (docker compose env_file) is real process env; without force
and <server>, it wins over these values. -->
<env name="APP_ENV" value="testing" force="true"/>
<env name="DB_DATABASE" value="app_testing" force="true"/>
<env name="CACHE_STORE" value="array" force="true"/>
<env name="QUEUE_CONNECTION" value="sync" force="true"/>
<env name="MAIL_MAILER" value="array" force="true"/>
<env name="SESSION_DRIVER" value="array" force="true"/>
<server name="APP_ENV" value="testing" force="true"/>
<server name="DB_DATABASE" value="app_testing" force="true"/>
<server name="CACHE_STORE" value="array" force="true"/>
<server name="QUEUE_CONNECTION" value="sync" force="true"/>
<server name="MAIL_MAILER" value="array" force="true"/>
<server name="SESSION_DRIVER" value="array" force="true"/>
</php>
Forcing the queue, mail and cache drivers matters for the same reason: otherwise tests can push jobs to the shared development Redis or send mail through the developer's SMTP settings.
2. Refuse to run against anything that is not a test database. The guard must run before
RefreshDatabase does. In Laravel, setUp() creates the application and then immediately runs the
traits' setup, so a check placed after parent::setUp() is too late. We override
createApplication() instead, which runs first:
<?php
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
public function createApplication()
{
$app = parent::createApplication();
$connection = $app['config']->get('database.default');
$database = (string) $app['config']->get("database.connections.{$connection}.database");
if ($app->environment() !== 'testing' || ! str_ends_with($database, '_testing')) {
fwrite(STDERR, sprintf(
"\nRefusing to run tests: APP_ENV=%s, database=%s. Expected APP_ENV=testing and a *_testing database.\n",
$app->environment(),
$database,
));
exit(1);
}
return $app;
}
}
Exiting stops the whole run on the first test, with one clear message, instead of letting 140 tests each fail while the next one might not.
3. Give tests their own database, created automatically by the MySQL container. Scripts in
/docker-entrypoint-initdb.d run when the data directory is initialised:
#!/bin/bash
# docker/mysql/init/01-create-testing-db.sh
set -e
mysql -uroot -p"$MYSQL_ROOT_PASSWORD" <<SQL
CREATE DATABASE IF NOT EXISTS app_testing CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
GRANT ALL PRIVILEGES ON app_testing.* TO '${MYSQL_USER}'@'%';
SQL
services:
mysql:
image: mysql:8.4
volumes:
- ./docker/mysql/init:/docker-entrypoint-initdb.d:ro
- mysql-data:/var/lib/mysql
Init scripts only run on an empty data directory, so existing volumes need the same SQL once by
hand. We added a make target for that.
Alternatives and trade-offs
- SQLite in memory for tests. Fast and isolated, but MySQL-specific behaviour (JSON columns, collations, strict mode, full-text indexes) goes untested. We use it for a fast unit subset only.
- A separate Compose service or profile for tests with its own environment and database container. The strongest isolation; slightly slower to start.
- Remove
env_fileand pass variables explicitly. Reduces surprises but makes the dev setup more verbose; the guard is still needed because shells and CI can export variables too. - A least-privilege database user for tests that has rights only on
app_testing. Worth doing in shared environments; on a laptop the guard gives the same protection with less setup.
How to test the fix
Prove each layer separately:
# 1. Forced values win even with the container env present.
docker compose exec app vendor/bin/phpunit --filter test_which_database
# testing / app_testing
# 2. The guard fires if someone breaks phpunit.xml.
docker compose exec -e DB_DATABASE=app app php -d variables_order=EGPCS \
vendor/bin/phpunit -c phpunit.broken.xml
# Refusing to run tests: APP_ENV=local, database=app. ...
# 3. The dev database is untouched after a full run.
docker compose exec mysql mysql -uroot -p -e "SELECT COUNT(*) FROM app.users"
phpunit.broken.xml is a copy with the force and <server> entries removed, kept in the
repository only for this check.
Confirming in production
There is no production impact to confirm, but we verified the developer environments: everyone
pulled the change, recreated their MySQL volume or ran the one-off SQL, and ran the full suite. Row
counts in the dev app schema were identical before and after on all six machines, and
app_testing contained only the expected migration state. The development database itself was
restored from the nightly dump we take of the shared seed data, which cost about an hour.
Regression guard
- The guard in
createApplication()is the backstop; it has already fired twice since, once for a CI job with a copied environment block and once after someone editedphpunit.xml. - CI runs the
phpunit.broken.xmlcheck above and expects exit code 1, so the guard itself is tested. - A code owner rule on
phpunit.xmlandtests/TestCase.phprequires a second reviewer.
Security implications
This incident wiped a development database, but the same configuration can point tests at any
database the environment names, including a staging or production replica if a developer's shell or
a CI job carries those credentials. That makes it a data-loss risk, not only an inconvenience. Keep
production credentials out of developer machines and test jobs entirely, give test runs a database
user limited to the test schema where practical, and keep the guard. Also remember that forced
<env> values in phpunit.xml are committed to the repository, so they must never contain real
secrets.
Prevention checklist
- Assume the process environment wins; use
force="true"plus<server>entries in containers. - Guard in
createApplication(), before any trait can migrate, and exit on a non-test database. - Create a dedicated
*_testingdatabase automatically in the database container. - Force queue, mail and cache drivers in tests so side effects cannot leak.
- Check for
bootstrap/cache/config.phpwhenever tests use unexpected settings.
References
- PHPUnit 11.5 manual, The XML configuration file:
<php>,<env>,<server>— https://docs.phpunit.de/en/11.5/configuration.html - Laravel 12 documentation, Database testing and
RefreshDatabase— https://laravel.com/docs/12.x/database-testing - Laravel 12 documentation, Testing environment — https://laravel.com/docs/12.x/testing#environment
- Docker documentation, Set environment variables in Compose (
env_file) — https://docs.docker.com/compose/how-tos/environment-variables/set-environment-variables/ - Docker Official Images, MySQL initialization scripts — https://hub.docker.com/_/mysql
- PHP manual,
variables_orderdirective — https://www.php.net/manual/en/ini.core.php#ini.variables-order
Add a label
Embed this post
<iframe src="https://hibug.me/phpunit-tests-wiped-dev-database-env-precedence" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I have spent most of my career on the side of software that pages people at night: payment integrations, web platforms with real traffic, CI/CD pipelines, Kubernetes clusters and the databases underneath them. Somewhere along the way I started keeping a notebook of every bug that cost more than an hour — the exact error, what we ruled out, the real root cause and how we proved the fix. Hibug is where those notes live. Every post follows the same path: Investigate, Explain, Fix, Verify — with the environment spelled out, the fix tested, and the security angle checked.
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.