Parallel test runs break on shared state, not CPU count. Here is how we isolated databases, temp files, locks, and reports in a Symfony coverage run, locked only the truly shared fixtures, and merged coverage without hiding failures.
TLDR
Parallel test failures usually come from shared state, not too few CPUs.
Give each process its own database suffix, and each queued test its own TMPDIR, lock store, JUnit file, and coverage file.
Lock only the fixtures that must stay shared, and keep those locks outside the per-test TMPDIR.
Warm one-time installs first, build the queue from phpunit --list-tests, and merge coverage with phpcov after the run.
Let the test exit code win, and treat any test that passes only on rerun as a bug report.
When a mature Symfony test suite gets slow, adding workers looks like the obvious fix. It usually isn't the first fix. Parallel test runs break on shared state, not on CPU count: two processes that both think they own the same database, temp file, lock, or report will produce failures that have nothing to do with the code under test.
The rule we follow is simple. Give every worker, and ideally every queued test, its own database, temporary directory, lock store, report file, and coverage file. Serialize the few resources that truly must stay shared with a narrow, explicit lock. Warm one-time installs before you fan out. Then merge the outputs and keep the test exit code in charge.
In October 2026 we applied this to the coverage run of a retail ecommerce integration middleware: a Symfony and Doctrine application whose suite exercises CSV imports, queues, and order and inventory flows. The client isn't named here, and the code below is trimmed from the real script and config. The lesson carries over to most PHP suites.
Start with a shared-state inventory
Before touching the test command, list every resource the suite creates, reads, overwrites, or reports into. For each one, decide whether it can be unique per process or has to stay shared.
Resource What we did Why it matters
Doctrine test database Appended the runner's process (channel) number to the test database name Fixtures and test data can't cross process boundaries
Temporary files Set TMPDIR per queued test (coverage/tmp/{n}) Generated and cached files don't collide
Symfony Lock store Pointed LOCK_DSN at a flock directory per queued test The lock backend stops being accidental shared infrastructure
JUnit reports One XML file per queued test No process overwrites another's results
Coverage data One .cov file per queued test, merged at the end Each process records coverage independently
CSV import fixtures in a shared directory An exclusive cross-process flock per fixture file Tests that must modify and restore the same file take turns
An inventory like this is worth doing even if you never parallelize. Flaky tests usually point at unclear ownership of files and data. Finding them early is a normal part of our custom software development work.
Give every process its own environment
The runner here is liuggio/fastest, which reads a queue of tests from standard input and runs them across N processes. Its command template has two placeholders: {p} is the process (channel) number and {n} is a unique number for each queued test.
For the database, the Doctrine test config appends both the ParaTest convention and the fastest channel to the database name:
when@test:
doctrine:
dbal:
# "TEST_TOKEN" is typically set by ParaTest
# "ENV_TEST_CHANNEL" is typically set by liuggio/fastest ({p})
dbname_suffix: '_test%env(default::TEST_TOKEN)%%env(default::ENV_TEST_CHANNEL)%'
For files, locks, and reports, the script keys everything on {n}, so each queued test gets its own directory and output files:
FASTEST_ISOLATION_ENV="TMPDIR=\"$COVERAGE_BASE_DIR/coverage/tmp/{n}\" LOCK_DSN=\"flock://$COVERAGE_BASE_DIR/coverage/tmp/{n}/locks\"";
FASTEST_COMMAND="$COVERAGE_COMMAND_PREFIX ./bin/phpunit -c \"$PHPUNIT_CONFIG\" {} --colors=never --coverage-php $COVERAGE_BASE_DIR/coverage/php/{n}.cov --log-junit $COVERAGE_BASE_DIR/coverage/junit/{n}.xml";
FASTEST_EXECUTE="mkdir -p \"$COVERAGE_BASE_DIR/coverage/tmp/{n}/locks\" && $FASTEST_ISOLATION_ENV $FASTEST_COMMAND";
Lock only what has to stay shared
Some state can't be split without changing what a test verifies. Import tests write CSV fixtures into the application's var/import directory. Two processes writing the same fixture file at once would corrupt each other's input.
The fix was a narrow lock per fixture file, kept outside TMPDIR so every process can see it:
// Shared across workers (do not use TMPDIR — coverage runner sets a per-method TMPDIR).
$lockDir = '/tmp/phpunit-import-fixture-locks';
if (!is_dir($lockDir)) {
mkdir($lockDir, 0777, true);
}
$lockPath = $lockDir.'/'.preg_replace('/[^A-Za-z0-9.-]/', '', $filename).'.lock';
$handle = fopen($lockPath, 'c+');
if (!flock($handle, LOCK_EX)) {
fclose($handle);
throw new \RuntimeException(sprintf('Unable to acquire import fixture lock "%s"', $lockPath));
}
$this->importCommandFixtureLocks[$filename] = $handle;
Isolate by default, then lock the smallest shared thing that can't be isolated, and name the lock after it.
Warm one-time setup before you fan out
Warm the Symfony PHPUnit bridge install once so parallel workers do not race on first boot.
$PHP_BINARY ./bin/phpunit -c "$PHPUNIT_CONFIG" --version >/dev/null;
Let PHPUnit build the queue
Build the queue from bin/phpunit --list-tests, not find tests/ -name "*Test.php". That skips abstract base classes. Process count comes from PARALLEL_PROCESSES, then nproc / sysctl, then a fallback of 4.
Treat reruns as bug reports
--rerun-failed can keep one unlucky collision from failing a pipeline — and hide a race inside a green build. A test that passes only on rerun is a bug report, not a pass.
Merge coverage without letting it mask failures
./vendor/bin/fastest -vvv --ansi --process "$PROCESS_COUNT" --preserve-order --rerun-failed --no-errors-summary "$FASTEST_EXECUTE" < "$QUEUE_FILE";
FASTEST_EXIT=$?;
$PHP_BINARY "$PHPCOV_PHAR" merge \
--html "$COVERAGE_BASE_DIR/coverage/html" \
--cobertura "$COVERAGE_BASE_DIR/coverage/cobertura.xml" \
--text "$COVERAGE_BASE_DIR/coverage/coverage.txt" \
"$COVERAGE_BASE_DIR/coverage/php";
PHPCOV_EXIT=$?;
if [ "$FASTEST_EXIT" -ne 0 ]; then
exit "$FASTEST_EXIT";
fi
exit "$PHPCOV_EXIT";
Coverage still merges after a failed run, but the test result wins.
A practical sequence
Inventory every database, directory, lock backend, fixture, report, and coverage file the suite touches.
Make each one per process or per test.
Put narrow cross-process locks on what must stay shared.
Run one-time installs before starting workers.
Build the queue from phpunit --list-tests.
Merge coverage after the run, and exit with the test runner's status first.
Log every rerun and fix the test behind it.
If CI is slow and parallel runs feel risky, start with the shared-state inventory. For more on how we approach this kind of engineering work, see our custom software practice and engineering strategy pages — or read the full original with sources.
Written by Willem Luijt at Endertech. Originally published at https://endertech.com/blog/isolate-shared-state-before-adding-parallel-test-workers.
Top comments (0)