Databases Bug Report

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.

Daniel Tran Daniel Tran @fun_dev · July 24, 2025 · 11 min read · 53 Views
Banner: a test runner pointing at the development database instead of the test database inside a container

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.php file makes Laravel ignore environment variables entirely, and it is a common cause of this exact symptom. The file did not exist.
  • A .env.testing file. Laravel loads it when the environment is testing, but the environment never became testing, and dotenv files do not overwrite variables that already exist anyway.
  • A rogue test calling migrate:fresh directly. The only caller was the RefreshDatabase trait, 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:

  1. PHP at startup. The CLI copies the process environment into $_SERVER. If variables_order contains E (the built-in default EGPCS, used when no php.ini sets it, as in the official Docker images unless you copy one in), it also fills $_ENV.
  2. PHPUnit's <php> section. For <env>, PHPUnit calls putenv() only if getenv() 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 a force attribute on <server> too, but in PHPUnit 11 assignment there is already unconditional.
  3. Laravel's environment repository. Env::get() uses phpdotenv with the server-variable adapter first, then the $_ENV adapter, then getenv(). 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

  1. 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.
  2. A separate Compose service or profile for tests with its own environment and database container. The strongest isolation; slightly slower to start.
  3. Remove env_file and 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.
  4. 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 edited phpunit.xml.
  • CI runs the phpunit.broken.xml check above and expects exit code 1, so the guard itself is tested.
  • A code owner rule on phpunit.xml and tests/TestCase.php requires 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 *_testing database automatically in the database container.
  • Force queue, mail and cache drivers in tests so side effects cannot leak.
  • Check for bootstrap/cache/config.php whenever tests use unexpected settings.

References

Add a label

#laravel #docker #environment-variables #mysql #phpunit
Written by
Daniel Tran @fun_dev

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.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.