Stop your agents fighting over one test database
So here is a problem I didn’t have a year ago. I’ve got three coding agents running at once, each in its own git worktree, each beavering away on a different feature of the same Laravel app. Lovely. Free parallelism. Until two of them run the test suite at the same moment and the wheels come off.
The failures were the worst kind. A test that passed on its own would blow up when I re-ran the suite, then pass again on the third go. No code change, no pattern, just noise. Classic flaky-test behaviour — except the code was fine. The plumbing wasn’t.
Every worktree on my machine shared one Postgres database: myapp-test. And the suite uses Laravel’s RefreshDatabase trait, which does exactly what it says — wipes the tables and re-migrates between runs. So when agent A is halfway through a feature test and agent B kicks off its own suite, agent B truncates the tables out from under agent A. Agent A’s test sees an empty database where it expected a seeded user, and falls over. Two processes, one database, no manners.
The fix is obvious once you say it out loud: give every worktree its own database. The interesting bit is doing that without breaking CI, without a pile of Docker, and without remembering to do anything by hand — because the whole point of handing work to agents is that you’re not babysitting them.
Why --parallel doesn’t already save you
First dead end, because I went down it. Laravel has parallel testing built in — php artisan test --parallel — and it handles databases for you. It spins up one database per worker process and suffixes them myapp-test_test_1, myapp-test_test_2, and so on. Job done, surely?
No. That isolates the workers within a single run. It does nothing across runs. Every invocation of the suite, in every worktree, still starts from the same base name myapp-test and appends _test_1 to it. So worktree A’s first worker and worktree B’s first worker both want myapp-test_test_1, and you’re right back to two processes sharing one database.
The thing that needs isolating isn’t the worker. It’s the worktree. The worktree is the unit of concurrency here — one worktree, one agent, one feature — and that’s the level the database name has to vary at. The per-worker suffix can then sit happily on top.
So the shape is: myapp-test → myapp-test-<worktree> → myapp-test-<worktree>_test_N. Two layers, each owning a different axis of parallelism.
A thin shim in front of artisan
I didn’t want to touch how anyone actually runs the tests. composer test should stay composer test. CI should carry on exactly as it does. The isolation needed to be opt-in and invisible when it’s off.
So I dropped a small PHP wrapper at bin/test and pointed the composer script at it:
composer.json — point the test script at the wrapper:
"scripts": {
"test": [
"@php bin/test"
]
}
The wrapper’s whole job is to optionally rewrite DB_DATABASE before handing off to artisan. It’s gated behind a single toggle in .env.testing, which is gitignored — so it’s a per-machine, per-developer choice and never lands in the repo:
.env.testing — your gitignored test config, with the new toggle:
DB_CONNECTION=pgsql
DB_DATABASE=myapp-test
# Flip this on when you're running multiple worktrees/agents on one box
TEST_DB_PER_WORKTREE=true
Toggle off — and it’s off for everyone by default, including CI — and bin/test is a straight pass-through to php artisan test --parallel. Nobody who doesn’t want this has to think about it. That mattered to me more than the clever bit.
Here’s the wrapper, trimmed of its comments:
bin/test — the whole wrapper (make it executable: chmod +x bin/test):
#!/usr/bin/env php
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use App\Support\TestDatabaseResolver;
use Dotenv\Dotenv;
$root = dirname(__DIR__);
if (file_exists($root . '/.env.testing')) {
// Unsafe variant adds putenv() so the OS env propagates to the
// pcntl_exec'd artisan child.
Dotenv::createUnsafeMutable($root, '.env.testing')->load();
}
$toggle = strtolower((string) (getenv('TEST_DB_PER_WORKTREE') ?: ''));
if ($toggle === 'true' || $toggle === '1') {
$base = (string) (getenv('DB_DATABASE') ?: 'myapp-test');
$derived = TestDatabaseResolver::derive($base, basename($root));
TestDatabaseResolver::ensureExists(
name: $derived,
host: (string) (getenv('DB_HOST') ?: '127.0.0.1'),
port: (int) (getenv('DB_PORT') ?: 5432),
user: (string) (getenv('DB_USERNAME') ?: ''),
password: (string) (getenv('DB_PASSWORD') ?: ''),
);
putenv("DB_DATABASE={$derived}");
$_ENV['DB_DATABASE'] = $derived;
$_SERVER['DB_DATABASE'] = $derived;
}
$args = array_merge([$root . '/artisan', 'test', '--parallel'], array_slice($argv, 1));
pcntl_exec(PHP_BINARY, $args);
The one fiddly detail worth knowing: I hand off to artisan with pcntl_exec rather than shelling out. It replaces the current process with artisan rather than spawning a child, so signals, exit codes and your terminal all behave as if you’d run artisan directly. But it means the new DB_DATABASE has to be in the actual OS environment, not just PHP’s $_ENV — hence loading the dotenv file with the unsafe mutable variant, which calls putenv() for you. That cost me twenty minutes of “why is it still using the old database”, so I’m saving you the same.
Deriving the name
The naming logic lives in its own little class, TestDatabaseResolver, kept separate purely so I could unit-test it without booting Laravel. It takes the base name and the worktree’s directory name and stitches them together:
app/Support/TestDatabaseResolver.php — the derive() method:
public static function derive(string $base, string $worktreeBasename): string
{
$suffix = strtolower($worktreeBasename);
$suffix = preg_replace('/[^a-z0-9]+/', '-', $suffix) ?? '';
$suffix = trim($suffix, '-');
if ($suffix === '') {
$suffix = 'worktree';
}
$derived = "{$base}-{$suffix}";
if (! str_contains(strtolower($derived), 'test')) {
throw new InvalidArgumentException(/* refuse — see below */);
}
if (strlen($derived) > self::MAX_DERIVED_LENGTH) {
throw new InvalidArgumentException(/* too long — see below */);
}
return $derived;
}
Nothing magical. Lowercase the worktree name, replace anything that isn’t a letter or number with a hyphen, collapse the repeats, trim the ends. A worktree at ~/Sites/myapp-payments becomes the suffix myapp-payments; one called feat/new checkout! becomes feat-new-checkout. If it sanitises down to nothing, fall back to the literal worktree so you always get a valid name.
I went with the worktree’s directory name rather than the git branch on purpose. Branch names are a mess — slashes, capitals, the occasional emoji from someone’s commit-hook setup — and they change while you work. The worktree directory is stable for the life of the worktree, which is exactly the lifetime I want the database to match.
End to end, with two agents running, you get this:
~/Sites/myapp-payments → myapp-test-payments → myapp-test-payments_test_1, _test_2
~/Sites/myapp-search → myapp-test-search → myapp-test-search_test_1, _test_2
Four databases, two worktrees, nobody treading on anybody. The per-worker suffix that Laravel adds is doing its normal job; my suffix just sits underneath it.
Creating the database without a race
The wrapper provisions the database if it’s missing, by connecting to Postgres’ own postgres admin database and issuing a CREATE DATABASE. The catch: when you fire off three agents at once, more than one of them can run this check at the same instant, all see the database doesn’t exist, and all try to create it. One wins, the rest throw.
Postgres has a specific error for that — 42P04, duplicate_database — so I check for it and treat it as success. It’s not a failure, it just means another process beat me to it, which is precisely what I wanted to happen anyway:
app/Support/TestDatabaseResolver.php — the heart of ensureExists():
$stmt = $pdo->prepare('SELECT 1 FROM pg_database WHERE datname = :name');
$stmt->execute(['name' => $name]);
if ($stmt->fetchColumn()) {
return; // already there
}
try {
$pdo->exec(sprintf('CREATE DATABASE "%s"', $name));
} catch (PDOException $e) {
// 42P04 = duplicate_database. Another process won the race
// between our SELECT and CREATE — treat as success.
if (($e->errorInfo[0] ?? null) !== '42P04') {
throw $e;
}
}
Idempotent, race-safe, no locking. The database name itself is also checked against ^[a-z0-9_-]+$ before it ever reaches that CREATE — interpolating a name into raw SQL makes me twitch, so anything with a quote, a semicolon or whitespace gets rejected outright rather than executed.
Belt and braces
This is the part I’d push hardest. You are writing code that creates and destroys databases, driven by an environment file, on a machine that also has your development database sitting right there. The failure mode isn’t a flaky test — it’s RefreshDatabase cheerfully truncating the data you’ve been building up locally all week. So there are guards, and they’re deliberately paranoid.
The name must contain the string test. If your derived name doesn’t — say someone set DB_DATABASE=myapp-dev by mistake — derive() refuses to return it and the run aborts. That single check is the difference between “tests failed” and “where’s all my data gone”.
There’s also a length guard. Postgres caps identifiers at 63 bytes, and Laravel’s parallel suffix eats up to eight of those, so the base I hand back has to fit in 55. If your worktree name is a novel, you get told to shorten it rather than a baffling truncation error deep in the test run.
And then — because env files lie and toggles get fat-fingered — the same contract is re-asserted at runtime, inside the test boot itself, where it’s too late to do any damage but early enough to refuse to run. Both the base TestCase and Laravel’s parallel-testing hook check it:
tests/TestCase.php — inside setUp(), runs before every test:
$db = DB::connection()->getDatabaseName();
if (config('app.env') !== 'testing') {
$this->fail('Refused: APP_ENV is not "testing".');
}
if (! str_contains(strtolower((string) $db), 'test')) {
$this->fail("Refused: DB name \"{$db}\" does not contain \"test\".");
}
app/Providers/AppServiceProvider.php — inside boot(), runs once per parallel worker:
ParallelTesting::setUpProcess(function (int $token): void {
$db = (string) DB::connection()->getDatabaseName();
if (app()->environment() !== 'testing' || ! str_contains(strtolower($db), 'test')) {
throw new RuntimeException('Refused to run tests against a non-test database.');
}
Artisan::call('migrate', ['--force' => true]);
});
Three checks for the same thing, in three places. That’s not an accident — it’s belt, braces and a second belt. When the cost of being wrong is your local data, redundant guards are cheap.
What I didn’t do
A few roads I looked at and turned down, in case you’re weighing the same ones.
Derive from the branch name. Tempting, because branches map neatly to features. But branches rename, get rebased and contain characters I’d spend more time sanitising than I saved. The worktree directory is boring and stable, which is what you want from an identifier.
A Postgres container per agent. This works, and if you’re already deep in Docker it might suit you. For me it was a lot of moving parts — port juggling, volume cleanup, slower startup — to solve a problem that one extra database per worktree solves for free. The databases are cheap; the containers weren’t.
In-memory SQLite for tests. Fast, fully isolated, no provisioning at all. But the app runs on Postgres in production, and the moment you lean on a Postgres-specific feature your SQLite tests quietly stop telling the truth. I’d rather test against the thing I deploy.
So that’s it. About sixty lines of glue, no new dependencies, off by default, and my agents have stopped elbowing each other in the ribs. If you’re running more than one worktree on a machine and your suite touches a real database, you’ll hit this eventually — hopefully this saves you the afternoon I spent chasing a flake that was never in the code. Have you found a tidier way to slice it?
Filed under Developer. No comments, on purpose.