← Back to lab

phpunit.xml Forced SQLite. My Tests Still Wiped 392 Production Articles.

php artisan test dropped 392 production articles to 6 faker rows: config:cache silently overrides phpunit.xml env vars. The fix is a 12-line bootstrap guard.

phpunit.xml Forced SQLite. My Tests Still Wiped 392 Production Articles.

Command: php artisan test | Rows: 392 → 6 | Date: 2026-09 | Status: restored, guarded

One command turned 392 published articles into six faker rows. The phpunit.xml was correct — it has forced sqlite in-memory testing since the repo existed. The cached config simply outvoted it.

Context: I run a fleet of small sites on one VPS, and the Laravel apps are deployed as live checkouts on that box. AI agents and I work in those same checkouts every day. The dangerous actor is not CI — it's a shell with muscle memory.

What everyone believes

The standard Laravel test setup:

<!-- phpunit.xml -->
<php>
    <!-- Force SQLite in-memory database for tests - NEVER use production database -->
    <env name="DB_CONNECTION" value="sqlite"/>
    <env name="DB_DATABASE" value=":memory:"/>
</php>

PHPUnit writes those vars into the process environment before the framework boots. Your config/database.php resolves env('DB_CONNECTION') at boot, picks up the override, and every test runs on a throwaway in-memory sqlite that dies with the process. RefreshDatabase drops and re-migrates it on each run. That is the contract, and on my laptop it held for years.

What actually happens in a production checkout

The deploy pipeline runs php artisan optimize. Among other things, that bakes the fully-resolved config — production database credentials included — into bootstrap/cache/config.php.

From that moment, Laravel's config bootstrapper short-circuits: it requires the cached array and never evaluates the env() calls in your config files again. The phpunit.xml vars are set correctly in $_ENV. Nobody reads them. The comment in phpunit.xml says "NEVER use production database" — the author of that line knew the intent; the runtime disagreed.

So the chain on wipe day:

  1. php artisan test boots the app from the production checkout.
  2. Config loads from cache → database.default is the production MySQL connection.
  3. RefreshDatabase runs its migrate:fresh — DROP every table on the configured connection.
  4. Real schema comes back empty; whatever factory-driven test runs next leaves six rows behind.

No error, no warning, no prompt. The suite proceeds. The failure is invisible until someone counts articles.

The part that makes this trap selective: it only fires where a deploy has cached the config. Laptop dev — no cache, override works. CI container — fresh filesystem, no cache, override works. Production checkout — the one place the data is real. The footgun aims exactly where it hurts.

The guard

Twelve lines in the test bootstrap, running in createApplication() — which executes before RefreshDatabase gets near anything:

// tests/CreatesApplication.php
public function createApplication(): Application
{
    $app = require __DIR__.'/../bootstrap/app.php';
    $app->make(Kernel::class)->bootstrap();

    // Cached config ignores phpunit.xml env overrides — this exact
    // combination pointed tests at production once. Refuse to boot.
    if ($app->configurationIsCached()) {
        throw new \RuntimeException(
            'Refusing to run tests with cached config. Run: php artisan config:clear');
    }

    // Check the config the app will ACTUALLY use, not the env vars
    // you hoped would be read.
    $connection = $app['config']->get('database.default');
    $database   = $app['config']->get("database.connections.{$connection}.database");

    if ($connection !== 'sqlite' || $database !== ':memory:') {
        throw new \RuntimeException(
            "Refusing to run tests against '{$connection}:{$database}'.");
    }

    return $app;
}

Two checks, both necessary. The first catches the silent-override case even when the connection string looks fine — with a cached config you cannot trust anything derived from env. The second is the dead-man's switch: if the resolved connection is anything other than sqlite in memory, tests do not start. The error message tells the operator the fix, because the person who hits it is never the person who wrote it.

The restore, and the incident before it

Recovery came from the 03:00 JSON backup plus a SQL dump seven days older — backfilled where the backup window didn't reach. Content back, site whole.

This was the second production-content incident in five weeks on the same app:

IncidentTriggerDamageRestore
Augbulk update from a tinker session, mis-quoted value373 of 375 articles → content column set to literal $03:00 JSON backup
Sepphp artisan test with cached config392 articles → 6 faker rows03:00 JSON backup + SQL dump

The August one is its own lesson. A bulk update fired from an interactive tinker session; the shell quoting mangled the value, and what reached SQL was a bare $. The log shows parse errors from two shell attempts that day — the session fought the quoting, lost, and one mangled attempt still executed. Live pages rendered empty bodies.

The restore discipline from that incident is worth copying verbatim: only the broken rows, only the content column, inside a transaction, and updated_at left untouched — so the audit trail still shows what happened when. Surgical restores beat wholesale ones the moment any other write touched the table between backup and incident.

What fell out of the two incidents, as standing rules:

  • A backup script runs before every migration and every non-dry-run write — verified complete after writing, never auto-deleted, labeled by what triggered it.
  • The rule is in every agent brief. Agents are the most likely actor to run a destructive command cheerfully, mid-task, without the hesitation a human would feel.
  • Tests only run in a checkout that owns its .env and carries no cached config. The guard exists for the day that rule is forgotten.

What I'd change

The framework should ship this refusal: php artisan test knows configurationIsCached() is true and says nothing while every guarantee phpunit.xml promises is void. Until it does, the bootstrap guard is the seatbelt — and I need to copy it into every other Laravel checkout on the box, because right now exactly one of them can survive its own test suite.