Open-source project
paratestphp/paratest avatar
paratestphp/paratest

paratest runs PHPUnit across processes, which is why your static guard fires once per worker

:computer: Parallel testing for PHPUnit

2,518 stars267 forksPHPMIT

At a glance

What is it?
Parallel test execution for PHPUnit with no bootstrap to write, but a hard architectural consequence: each worker gets its own PHP interpreter, so static state, shared test class members and Reflection all stop working. Two more things to know before you adopt it, namely that the Composer package is `brianium/paratest` while the GitHub project is `paratestphp/paratest`, and that the `make` target contributors are told to run does not include the project's own mutation testing.
Who is it for?
paratest is the right tool the moment your suite is slow enough that you have stopped running it locally, and provided your tests keep their state inside the test rather than in statics, constants or other test classes. Four checks before you switch.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 1 day ago.
What is it written in?
Mainly PHP, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on November 13, 2026, and from our analysis. They are not legal advice.

From daily snapshots

Star history

  • paratestphp/paratest2,518 ★ · +8
Star history
Repository2026-09-292026-11-13
paratestphp/paratest2.5k2.5k

8 Stars in the last 45 days, measured from the daily GitHub snapshot. Compare with other projects

Editorial analysis

The install command says brianium/paratest, not paratestphp/paratest

The project carries three names, and a first-time user can miss the one that matters. The GitHub organisation is paratestphp. The Composer package is brianium:

bash
composer require --dev brianium/paratest

The build badge at the top of the file points at packagist.org/packages/brianium/paratest, while the repository itself is paratestphp/paratest. The installation section gives the brianium form and no alternative, so the two names are not interchangeable: searching the repository name in a `composer require` will not find the package.

A third detail sits in the badges. The mutation testing report is addressed to the 7.x branch, which is also the default branch here, while the tags on the recent releases run v7.24.0, v7.24.1 and v7.25.0. Anyone reading a mutation score is therefore reading the state of the development line rather than of whatever release their project happens to pin, and the two diverge on any day a commit lands.

TEST_TOKEN and UNIQUE_TEST_TOKEN solve two different collision problems

Two environment variables are provided, and the difference between them is the part worth knowing.

`TEST_TOKEN` is guaranteed to have a value different from every other currently running test. That is a concurrency guarantee, so it is safe as a suffix for a per test resource:

php
if (getenv('TEST_TOKEN') !== false) {  // Using ParaTest
    $dbname = 'testdb_' . getenv('TEST_TOKEN');
} else {
    $dbname = 'testdb';
}

`UNIQUE_TEST_TOKEN` is guaranteed to be unique both per run and per process, so it will not repeat across successive runs either. The first is scoped to what is running at this moment, which is enough to stop two workers colliding and is cheap to read. The second is scoped to history, which matters when a leftover resource from an earlier run would poison the current one.

The `getenv` check is also the compatibility hinge. The else branch is the single process case, so the same test file runs unchanged under plain PHPUnit.

A static guard runs once per worker, so the fix coordinates through a lock file

This is the one place where parallel execution changes the meaning of your code rather than just its speed. Each worker is a separate PHP interpreter, and static variables persist for the execution of a single process, so a flag stored in a static property is per process. A `setUp` guarded by that flag runs its initialisation once per worker, not once per suite, and the stated fix is to use the filesystem as shared mutable state.

The replacement shown touches `/tmp/test-initialization-lock-file`, opens the same path with `fopen`, and calls `flock` with `LOCK_EX | LOCK_NB`. A process that wins the exclusive lock runs the initialisation; the sample then opens an else branch, and that is where it stops, so what a losing process does next is not shown.

One detail is easy to skim past. The pattern that fails declares the flag with `private static`, while the pattern that works drops the keyword. Visibility has nothing to do with it. The cause is process isolation, and the fix for a lone file is coordination rather than scope. The fixed path under `/tmp` is also shared by every project on the machine.

pcov has to be handed to every subprocess, xDebug does not

Coverage configuration differs between the two engines, and the difference is not cosmetic. xDebug activates through an environment variable, which children inherit, so one assignment is enough:

bash
XDEBUG_MODE=coverage vendor/bin/paratest

pcov is enabled with a PHP binary option, and binary options do not travel to workers on their own. If pcov is installed but you want it active only during the run, the option has to be passed through to each child:

bash
php -d pcov.enabled=1 vendor/bin/paratest --passthru-php="'-d' 'pcov.enabled=1'"

Note that the outer `-d` covers the parent and `--passthru-php` covers the children, and both are needed. A run that reports coverage for the parent process alone is the usual symptom of omitting the second half.

One behaviour sits above both: the cache is always warmed up before the test suite executes, so the first parallel run is slower than the ones after it regardless of which engine you chose.

Internal commands inject a null printer, so PHPUnit output is silent by design

Every internal command runs with `--printer [...]NullPhpunitPrinter`, which silences the original PHPUnit output. That is why a failing parallel run can look sparse: the runner is suppressing the test framework's own reporting on purpose.

The escape hatch is built in. When a subprocess fails, the originating command is written into the output so it can be copied into a terminal and run on its own, and the guidance is to remove the printer option during a debugging run to restore what PHPUnit is doing. `--verbose` is the other lever, described as the way to get more information about a problem.

Read together, the two paragraphs describe a deliberate trade. Interleaved output from a dozen workers is unreadable, so it is suppressed, and the cost is paid at the moment you need it. Anyone reaching for a failing suite should strip the printer option first and only then start reading stack traces, because the trace ParaTest prints for you is the command, not the assertion.

Statics and shared test class state are unsupported, and WrapperRunner is the reason given

One caveat covers a wide surface: constants, static methods, static variables, and everything exposed by test classes that other test classes consume, including through Reflection, are not supported. The stated cause is a limitation of the current implementation of `WrapperRunner` together with how PHPUnit searches for classes.

The prescribed fix is structural rather than a flag. Shared code belongs in classes which are not tests themselves, so it is not subject to how the runner discovers and loads test classes. That is a real refactor rather than a configuration change, and it has to happen before the suite can run in parallel at all.

This is the same fact as the `setUp` guard seen from the other side. Static state does not survive the process boundary, and neither does anything hanging off another test class. Together they define the migration: state moves into the test, helpers move out of the test namespace, and the once-per-suite guard moves to a lock.

make omits mutation testing, and contributors are told to run make

The contributor instruction is one sentence: run all the necessary checks with the `make` command before opening a pull request. The Makefile disagrees with itself about what that means. A commented out line sits directly above the live one:

`#all: csfix static-analysis code-coverage`

`all: csfix static-analysis test`

So the default target runs the coding standard fixer, PHPStan and the PHPUnit suite, and not the `code-coverage` target, which is the one that runs Infection. The project ships `infection.json`, the Makefile passes Infection `--git-diff-lines` with a `--git-diff-base` computed from your local base branch, and `--threads=$(nproc)` beside it. Mutation testing is wired up, configured to look only at changed lines, and then left out of the command the README tells every contributor to run.

Two other behaviours in the same file are worth knowing. `csfix` runs `phpcbf` with its failure deliberately swallowed so the fixer can do its work before `phpcs` decides the verdict. And the coverage target removes its own JUnit artefact on failure rather than leaving a stale report behind.

docker-compose interpolates a variable the generated .env never defines

The compose file builds one service and mounts the working tree into it:

yaml
services:
  php:
    build:
      context: .
      args:
        USER_ID: ${USER_ID}
        GROUP_ID: ${GROUP_ID}
    volumes:
      - .:${PWD}
    working_dir: ${PWD}

Four interpolations, three of them user and group identifiers, and a fourth, `PWD`, used twice. The Makefile is what supplies the first three: its `.env` target writes a file containing `USER_ID` and `GROUP_ID` from `id --user` and `id --group`, and rebuilds it whenever `/etc/passwd`, `/etc/group` or the Makefile changes. Nothing in that file defines `PWD`.

The `Dockerfile` has its own unpinned edge. It starts `FROM php:8.5`, installs git with the comment that it is needed for Infection, then downloads the extension installer from a `releases/latest/download` URL, so the tool that adds pcntl and pcov floats to whatever that repository published most recently at build time. `docker compose build --pull` then refreshes the base image on the same run. Nothing in the dependency chain is pinned to a version.

Editorial conclusion

paratest is the right tool the moment your suite is slow enough that you have stopped running it locally, and provided your tests keep their state inside the test rather than in statics, constants or other test classes. Four checks before you switch. Read the caveat about `WrapperRunner` and shared test class state first, because that is the failure that will not reproduce on a single process and will not point at ParaTest. Replace any once-per-suite `setUp` guard with the filesystem lock pattern, or the guard will run once per worker and quietly multiply your setup cost by the process count. Confirm your coverage engine, because pcov has to be re-passed into every subprocess and xDebug does not. And run your suite sequentially once with the null printer removed, so you can see what PHPUnit is actually reporting before the runner hides it. If your tests share a database, a fixture or a helper defined in a test class, this is not a drop-in replacement.

Frequently asked questions

What does paratest actually do?

It runs your PHPUnit tests across multiple parallel processes, either by TestCase with `vendor/bin/paratest` or by Test with `vendor/bin/paratest --functional`, and combines the code coverage output from all of those processes into a single report. No extra bootstrap is required.

How do I install paratest with composer?

Run `composer require --dev brianium/paratest`. The package name is not the same as the GitHub project name, which is paratestphp/paratest, and both names appear in the project's own badges.

Which PHPUnit versions does paratest support?

Only the latest one. The stated reasons are that from version 5 ParaTest relies heavily on PHPUnit `@internal` classes, and that the pace of PHP and PHPUnit changes leaves a maintenance budget that only covers the newest releases.

Why does my static initialisation flag run once per process in paratest?

Static variables persist only for one PHP process, and ParaTest gives every worker its own interpreter, so the flag is per process rather than per suite. The documented replacement coordinates through a lock file with `flock` using `LOCK_EX | LOCK_NB`, where the first process to take the lock runs the initialisation.

Why is there no PHPUnit output when a paratest worker fails?

All internal commands run with `--printer [...]NullPhpunitPrinter`, which silences the original PHPUnit output. The failing command itself is printed so it can be copied into a terminal, and removing that printer option during a debugging run restores the output.

Official sources

  1. Issues
  2. License: MIT
  3. paratestphp/paratest on GitHub
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/paratestphp-paratest.svg)](https://hysenlabs.com/projects/paratestphp-paratest)

Star history chart for your README

markdown
[![Star history](https://hysenlabs.com/chart/paratestphp-paratest.svg)](https://hysenlabs.com/projects/paratestphp-paratest)

Was this useful?