Skip to content

Laravel Tests Fail When Two Runs Overlap, Even With --parallel

Package

mozex/laravel-test-lanes

Give every concurrent Laravel test run its own databases by claiming a machine-unique lane for each process

1,476
downloads
1
stars
Read the Docs

Whenever two test commands ran inside one of my projects at the same time, they failed, because one run would clear the database under the other. It happened especially when I asked a Claude Code session to hand work to sub-agents: each sub-agent ran the tests for its own change, the runs failed, and it became a loop of discovering the problem and running it again.

For this post I reproduced it on a fresh Laravel app whose phpunit.xml points at MySQL, with 1,201 tests, 1,200 of them using RefreshDatabase. Run alone, before any fix, the suite passes in under a minute. I started php artisan test twice at the same moment, three times. All six runs failed 1,167 to 1,200 of their 1,201 tests, and each took 3.5 to 4.4 minutes.

The fix is my package mozex/laravel-test-lanes. Install it with composer require mozex/laravel-test-lanes --dev and there's nothing else to set up. With it, those runs pass. Everything here ran on Laravel 13.33, PHP 8.5, PHPUnit 12.5 with ParaTest 7.20, and MySQL 8.4 on Windows 11, with version 1.1.1 of the package (Laravel 11 to 13, PHP 8.2+, MySQL, MariaDB and PostgreSQL).

Serial runs share one database, and RefreshDatabase keeps retrying

Without --parallel, Laravel never switches databases. Every serial run of the suite uses the database phpunit.xml names, here lanesapp_testing. RefreshDatabase runs migrate:fresh before a process's first test, then wraps each test in a transaction. Two runs at once drop and create the same tables under each other. Two of the messages, cut before the SQL:

SQLSTATE[42S01]: Base table or view already exists: 1050 Table 'users' already exists
SQLSTATE[42S02]: Base table or view not found: 1146 Table 'lanesapp_testing.migrations' doesn't exist

Almost every test failed, not just the first few, because RefreshDatabase marks the migration as done only after migrate:fresh succeeds. When it throws, the next test runs migrate:fresh again, which drops the other run's tables, which makes the other run's migration throw. All 7,158 failures in those six runs came from migration statements, and none from a test's own queries.

--parallel numbers its databases from 1 in every run

Laravel's docs say parallel test databases are "suffixed with a process token which is unique per process", as in your_db_test_1 and your_db_test_2. Unique inside one run. The token is ParaTest's worker number, TEST_TOKEN. ParaTest counts from 1 every time it starts. With 24 processes (the default on this machine, which has 24 logical processors), two runs both use lanesapp_testing_test_1 to _test_24.

I started a second --parallel run 8 seconds after the first, three times. Both runs failed in every pair, 1,085 to 1,176 of 1,201 tests each. When I started both at the same moment, three times, one run of each pair stopped before its first test with mkdir(): File exists, a different collision (below), and the other passed because it had the databases to itself.

ParaTest also sets UNIQUE_TEST_TOKEN, "unique both per run and per process" according to its README. Laravel 13.33 never reads it, and a name that changes every run would leave a new database behind every run anyway.

A serial run next to a --parallel run didn't collide: one uses the base database, the other the _test_N ones. And on the skeleton's default in-memory SQLite there's nothing to collide on, because every process has its own database.

Lanes give every test process a database no other run is using

Install the package as a dev dependency:

composer require mozex/laravel-test-lanes --dev

The service provider registers itself when tests run under APP_ENV=testing, which the skeleton's phpunit.xml sets. In the test app, the install changed composer.json and composer.lock and nothing else.

Each test process then claims a lane: the lowest number from 1 to 256 whose advisory lock it can take (GET_LOCK on MySQL and MariaDB, pg_try_advisory_lock on PostgreSQL), on its own connection that Laravel doesn't manage. The lane becomes the token, so the process works in lanesapp_testing_test_lane1, _test_lane2 and so on, and Laravel creates each one on first use. Serial runs are routed the same way, so a plain php artisan test gets a lane instead of the shared base database.

The lock lasts as long as that connection. MySQL's docs say it is released "implicitly when your session terminates (either normally or abnormally)". I killed a --parallel run with taskkill /F /T ten seconds in, while it held lanes 1 to 24. A second later, none were held. There is no lock file to clean up after a crash, unlike a Redis queue lock, which stayed behind after I killed its worker with taskkill /F.

I repeated the pairs with the package installed. All 24 runs passed all 1,201 tests: three serial pairs started at the same moment, and nine --parallel pairs started 1, 3 or 8 seconds apart. Two runs at once used lanes 1 to 48, and after all the runs for this post the server held only those 48 lane databases, because a process always takes the lowest free lane. --parallel pairs started at the same moment are the exception (below).

Lanes cost no measurable time with --parallel, and about 15 ms per test serially

I switched the package off with TEST_LANES_ENABLED=false and on again, alternating runs. With --parallel there was no measurable difference: a median of 21.9 seconds off and 22.1 on, five runs each. Two --parallel runs at once took 31 to 47 seconds each, against 22 alone, because they share one machine.

Serial runs did pay. Over three runs each, the median was 54.8 seconds off and 73.1 on, a difference of about 15 milliseconds per test. The package sends serial runs through Laravel's parallel-testing setup, and that setup switches the connection to the test database before every test. The server's connection counter rose by 2,403 during one serial run with the package on and by 1,202 with it switched off. A --parallel run goes through that setup anyway. With 24 processes, --parallel also ran this suite in less than half the serial time.

Each test process holds two connections, one to its lane database and one for the lock. One --parallel run peaked at 49 open connections on the server with the package on and 25 with it switched off, my sampling connection included. Two runs at once peaked between 97 and 106 in the four pairs I measured, up to 10 above twice their 48 processes. This MySQL server allowed 151. PostgreSQL's default is "typically 100", so set max_connections to at least twice the number of test processes in all the runs you expect at once, plus headroom.

What lanes don't isolate

One part of Laravel's parallel runner still uses the worker number: it creates and deletes the compiled-view folders storage/framework/views/test_1 to test_24 from the parent process. With the package installed, I started three more --parallel pairs at the same moment. In two of them, one run stopped with mkdir(): File exists; in the third, one run passed every test and still exited with code 1, because the other run deleted one of those folders while this run was deleting it. Starting the second run a second or more later avoided the collision in all nine pairs I tried.

The same goes for anything else your suite keys on a fixed path or on ParaTest's raw TEST_TOKEN. A SQLite database file is refused with an exception that names TEST_LANES_ENABLED=false as the way out, and the package's docs cover the other setups it refuses or can't serve, such as a DB_URL-style connection and PgBouncer in transaction-pooling mode.

Lane databases stay between runs for reuse. php artisan test-lanes:cleanup drops every one whose lock is free, but it takes the database name from .env, not phpunit.xml. In my test app it dropped nothing until I ran it as DB_DATABASE=lanesapp_testing php artisan test-lanes:cleanup, which dropped all 48.

What else collides when your agents run tests side by side?

Common questions

Why do Laravel tests fail when two test runs happen at the same time?
Both runs use the same test database: a serial run uses the database named in phpunit.xml, and two --parallel runs both use _test_1 upward. RefreshDatabase runs migrate:fresh at the start of each process, so one run drops the tables the other is using. After a failed migrate:fresh it tries again before the next test, which keeps the two runs breaking each other.
How do I stop two Laravel test runs from sharing a database?
Give each test process a database that no other run is using. With mozex/laravel-test-lanes installed as a dev dependency, each process claims the lowest free lane with a database advisory lock and works in its own lane database, for serial and --parallel runs alike. The lock is released with the process's connection, so a killed run frees its lanes.
Doesn't php artisan test --parallel give each run its own databases?
No. It gives each process inside one run its own database, named after ParaTest's worker number, and ParaTest counts its workers from 1 in every run. Two runs with 24 processes both use your_db_test_1 to your_db_test_24.
Does ParaTest's UNIQUE_TEST_TOKEN keep two Laravel test runs apart?
No. ParaTest sets it, unique per run and per process, but Laravel 13.33 never reads it. Laravel names parallel test databases from TEST_TOKEN, the worker number.
Does mozex/laravel-test-lanes slow tests down?
With --parallel, not measurably: a median of 21.9 seconds with it switched off and 22.1 with it on, on a 1,201-test suite. Serial runs paid about 15 milliseconds per test (54.8 against 73.1 seconds), because they now switch database connections before every test. Two runs at once are slower than one because they share the machine.
How many database connections do test lanes need?
Two per test process: one to its lane database and one holding the lock. A 24-process run peaked at 49 open connections, one of them a sampling connection, and two at once peaked between 97 and 106 in four measured pairs, up to 10 above twice their 48 processes, so set max_connections to at least twice the number of test processes in all the runs you expect at once, plus headroom.

Share this post

Share on X
Share on LinkedIn
Share on Reddit
Share on Hacker News
Copy link

Stay in the Loop

Get the latest posts delivered to your inbox - on your schedule.

No spam. Unsubscribe anytime.

Scroll to top