ParaTest: Run PHPUnit Tests in Parallel
A PHPUnit test suite that takes 10 minutes to run is a test suite that developers learn to avoid. ParaTest splits your test files across multiple parallel processes, turning a 10-minute run into a 2-minute run without changing a single test. This guide covers setup, database isolation, and the patterns that make parallel testing reliable.
How ParaTest Works
PHPUnit runs tests serially — one test at a time in a single process. ParaTest spawns multiple PHPUnit worker processes and distributes test files across them:
Serial PHPUnit: [file1] → [file2] → [file3] → [file4] → [file5] = 50s
↕
Parallel (5): [file1] = 10s
[file2]
[file3]
[file4]
[file5]The speedup is roughly proportional to the number of workers (bounded by I/O and database contention).
Installation
composer require --dev brianium/paratestBasic Usage
# Run with default worker count (= CPU count)
vendor/bin/paratest
# Specify worker count explicitly
vendor/bin/paratest --processes 4
# Use same phpunit.xml config
vendor/bin/paratest --configuration phpunit.xml
# Verbose output
vendor/bin/paratest --processes 4 --verboseParaTest reads your existing phpunit.xml — no configuration migration required.
Database Isolation
The hard part of parallel testing is database isolation. Without it, worker processes read each other's data and tests fail non-deterministically.
Strategy 1: Separate Databases Per Worker (Recommended)
Create one test database per worker using the TEST_TOKEN environment variable that ParaTest injects:
<?php
// config/database.php
'mysql' => [
'driver' => 'mysql',
'host' => env('DB_HOST', '127.0.0.1'),
'database' => env('DB_DATABASE', 'laravel') . '_' . (env('TEST_TOKEN') ?? ''),
// ...
],When ParaTest runs 4 workers, it sets TEST_TOKEN to 1, 2, 3, 4. The databases become laravel_test_1, laravel_test_2, etc.
Create the databases before running:
#!/bin/bash
# scripts/setup-parallel-test-dbs.sh
WORKERS=${1:-4}
for i in $(seq 1 $WORKERS); do
mysql -u root -proot -e "CREATE DATABASE IF NOT EXISTS laravel_test_${i};"
DB_DATABASE="laravel_test_${i}" php artisan migrate --force
doneAdd to CI:
- name: Create parallel test databases
run: bash scripts/setup-parallel-test-dbs.sh 4Strategy 2: Transaction Rollback (Laravel)
For Laravel with RefreshDatabase or DatabaseTransactions traits, parallel tests are safe because each test wraps its changes in a transaction that gets rolled back:
class UserTest extends TestCase
{
use RefreshDatabase; // Each test gets a clean slate via transactions
public function test_creates_user(): void
{
// This transaction is rolled back after the test
User::factory()->create(['email' => 'test@example.com']);
$this->assertDatabaseHas('users', ['email' => 'test@example.com']);
}
}However, RefreshDatabase recreates the schema before each test (slow). For parallel execution, DatabaseTransactions is faster:
class UserTest extends TestCase
{
use DatabaseTransactions; // Wraps test in transaction, rolls back
public function test_creates_user(): void
{
User::factory()->create(['email' => 'test@example.com']);
$this->assertDatabaseHas('users', ['email' => 'test@example.com']);
}
}DatabaseTransactions assumes the schema already exists — run migrations once before tests, not per test.
phpunit.xml Configuration for Parallel Tests
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
bootstrap="vendor/autoload.php"
colors="true"
processIsolation="false">
<php>
<env name="APP_ENV" value="testing"/>
<env name="DB_CONNECTION" value="mysql"/>
<env name="DB_DATABASE" value="laravel_test"/>
</php>
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
</phpunit>Run specific suites in parallel:
# Only feature tests in parallel (unit tests are fast enough serial)
vendor/bin/paratest --processes 4 --testsuite FeatureTests That Cannot Run in Parallel
Some tests are fundamentally serial:
File System Operations
// Dangerous — multiple workers writing to same file
public function test_generates_report(): void
{
$this->artisan('reports:generate', ['--output' => '/tmp/report.csv']);
$this->assertFileExists('/tmp/report.csv');
}
// Safe — worker-specific path
public function test_generates_report(): void
{
$token = env('TEST_TOKEN', '0');
$path = "/tmp/report_worker{$token}.csv";
$this->artisan('reports:generate', ['--output' => $path]);
$this->assertFileExists($path);
}Cache and Session
Use array drivers for tests:
// config/testing/cache.php
return [
'default' => 'array',
];With the array cache driver, each process has its own in-memory cache — no cross-worker pollution.
Queue Testing
// config/testing/queue.php
return [
'default' => 'sync', // Jobs run synchronously in tests, no shared queue
];Measuring Speedup
Before optimizing, measure:
# Baseline: serial execution
time vendor/bin/phpunit --testsuite Feature
# Parallel with increasing workers
time vendor/bin/paratest --processes 2 --testsuite Feature
time vendor/bin/paratest --processes 4 --testsuite Feature
time vendor/bin/paratest --processes 8 --testsuite FeatureDiminishing returns appear around the number of CPU cores. Database I/O often becomes the bottleneck before CPU does.
CI Configuration
GitHub Actions with parallel test databases:
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: laravel_test
options: --health-cmd="mysqladmin ping" --health-interval=10s
ports:
- 3306:3306
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: 8.3
extensions: pdo_mysql
- name: Install dependencies
run: composer install --prefer-dist --no-interaction
- name: Create parallel databases
run: |
for i in 1 2 3 4; do
mysql -h127.0.0.1 -uroot -proot -e "CREATE DATABASE laravel_test_$i;"
DB_DATABASE=laravel_test_$i php artisan migrate --force
done
- name: Run parallel tests
run: vendor/bin/paratest --processes 4 --runner WrapperRunnerWrapperRunner vs ProcessRunner
ParaTest supports two execution modes:
ProcessRunner (default): Spawns a new PHP process per worker. Higher isolation, higher overhead.
WrapperRunner: Keeps PHP processes alive between test batches. Lower overhead, slightly less isolation.
# WrapperRunner is 15-30% faster for most suites
vendor/bin/paratest --processes 4 --runner WrapperRunnerUse WrapperRunner unless you have tests that pollute global PHP state between files.
Generating JUnit XML
vendor/bin/paratest \
--processes 4 \
--log-junit build/test-results/phpunit.xmlThe JUnit output merges results from all workers into a single file, compatible with CI test result visualization.
Debugging Parallel Failures
When a test passes serially but fails in parallel, the cause is almost always shared state:
- Database: Two workers inserting the same unique value → use
TEST_TOKENin DB name - Files: Two workers writing the same path → include
TEST_TOKENin path - Cache: Cached value from worker A read by worker B → use array cache driver
- Ports: Two workers binding the same port for a test server → use port 0 (random)
Run with --processes 2 first — easier to reproduce and debug than 8 workers.
Summary
ParaTest is a configuration change, not a testing framework change. Install it, point it at your existing phpunit.xml, and most test suites run in parallel immediately. The only work required is isolating any tests that share global state — and those tests are probably already fragile in serial mode, just less visibly so. Parallel execution makes test isolation problems obvious, which is a feature, not a bug.