manuglopez / phpunit-replay
Test Impact Analysis and result replay for PHPUnit: run only what your changes affect, replay the rest from cache.
Requires
- php: ^8.2
- ext-json: *
- ext-tokenizer: *
- phpunit/phpunit: ^11.5 || ^12.0
- symfony/console: ^6.4 || ^7.0 || ^8.0
- symfony/finder: ^6.4 || ^7.0 || ^8.0
- symfony/process: ^6.4 || ^7.0 || ^8.0
Requires (Dev)
- brianium/paratest: ^7.8
- laravel/pint: ^1.18
- phpstan/phpstan: ^2.1
Suggests
- ext-pcov: Fastest coverage driver for recording the dependency graph
- ext-xdebug: Alternative coverage driver (mode=coverage)
- brianium/paratest: Parallel execution support
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-07 12:55:58 UTC
README
Run only the tests your change could possibly affect. Replay everything else as a real pass — with its real assertion count — instead of skipping it.
Composer package manuglopez/phpunit-replay, namespace Manuglopez\Replay. Plain PHPUnit 11.5+/12, no dependency on Pest.
The problem
A PHPUnit suite grows with the codebase, but most of it is irrelevant to any single change. If a project has 3,000 tests and you edit one method in one class, the overwhelming majority of those tests import code that never calls, is never called by, and shares no runtime path with what you touched — nothing you did can change their outcome. Yet the default is to run all 3,000 of them, every time, on every commit, in every PR. That costs CI minutes that scale with test count instead of change size, and it costs developers a feedback loop measured in minutes when it could be measured in seconds.
Test Impact Analysis (TIA) is the answer: instead of guessing from file paths or naming
conventions, record which source files a test actually executed the last time it ran, then use
that recorded dependency to decide, on the next run, which tests a given change could possibly
affect. Say app/Services/Pricing.php changes. Only test files whose last recorded run actually
executed Pricing.php — directly, or several calls deep — are candidates for a different result;
every other test file's outcome is provably unchanged, because its last run never touched that
code. A README.md edit, a comment, or a config file no test ever reads through affects nothing at
all — the common case in most commits.
How phpunit-replay solves it
Three ideas, in the order they run:
(a) Record. While the suite runs once with pcov or Xdebug active as a raw coverage driver
(not PHPUnit's own --coverage-*, which stays off during recording), phpunit-replay watches which
source files execute while each test file's tests run, and reduces that to a dependency edge:
test file → source file. It also records each individual test's own result — status, assertion
count, message, duration — keyed by test id. Both are written to a single graph.json.
(b) Detect what changed, select what could differ. On a later run, phpunit-replay diffs the
working tree against the git commit the graph was recorded at, plus anything currently
staged/unstaged/untracked. A content hash that ignores comments and whitespace (tokenizer-based
for .php, similar normalization for Blade/JS/TS) drops cosmetic-only edits from that diff before
selection ever runs — a renamed variable re-runs tests, a reformatted docblock does not. What
remains goes through a chain of rules (see How selection works) that maps
changed files to the test files whose recorded edges include them. Anything unknown to the graph
(a new test) and any cached failure also always runs — a failure is never assumed fixed by itself.
(c) Replay the rest — as a pass, not a skip. Every test file not selected is never re-executed; its last recorded result is served instead. Critically, phpunit-replay reports that result as the same status it actually had — a pass with its real assertion count, a skip with its real message — not as a synthetic "skipped, not run" placeholder. That distinction is the reason for the name: results are replayed, not hidden.
| Executed this run | Replayed by phpunit-replay | Skipped (typical file-level TIA) | |
|---|---|---|---|
| Test body actually ran | yes | no | no |
| Counted in the summary totals | yes | yes | yes |
| Carries its real assertion count | yes (fresh) | yes (from the baseline) | no — assertions are lost |
Triggers --fail-on-skipped |
no | no | yes, if your CI enables it |
| Appears in JUnit as a complete test | yes | yes (replayed="true" property) |
yes, but marked <skipped/> |
An annotated real summary line, printed below PHPUnit's own output:
Replay ✓ 31 executed (31 affected, 0 uncached) · 4 replayed · 0 quarantined · baseline main@abc1234
| Segment | Meaning |
|---|---|
✓/✗ |
overall PHPUnit result for this pass |
31 executed |
ran for real this pass = affected + uncached + quarantined |
31 affected |
selected by a rule (PhpEdge, TestFile, Sibling, Blade, Migration, Watch) |
0 uncached |
new to the graph, or forced to re-run (cached failure, a risky/incomplete result your config surfaces) |
4 replayed |
served from cache as their real recorded status, not run |
0 quarantined |
content key unchanged but result flipped (see Keeping the cache honest) — always executed for real |
baseline main@abc1234 |
git branch + commit the graph was recorded against |
Totals stay honest either way: --fail-on-skipped is never tripped by a replayed test (it isn't a
skip), and --log-junit output produced by phpunit-replay contains one complete <testcase> per
test, real or replayed.
Install
composer require --dev manuglopez/phpunit-replay
Requirements:
| PHP | ^8.2 |
| PHPUnit | ^11.5 or ^12 |
| Git | a repository with at least one commit — baselines and diffs are computed against git history |
| Coverage driver | ext-pcov or Xdebug with xdebug.mode=coverage, to record the dependency graph |
phpunit.xml/phpunit.xml.dist |
any valid PHPUnit configuration |
| Optional | brianium/paratest (composer require --dev brianium/paratest) for --parallel/-p |
No php.ini changes are needed for pcov: the wrapper enables it per invocation with
-d pcov.enabled=1 -d pcov.directory=<project root> (pcov instruments nothing without an explicit
pcov.directory, even though phpinfo() shows a cwd-derived default). Without pcov or Xdebug,
phpunit-replay disables itself with a warning and PHPUnit runs exactly as it would unpackaged.
Quick start
$ vendor/bin/phpunit-replay status
root: /home/you/project
branch: main (default: main)
head: 0742ab4
state dir: ~/.phpunit-replay/project-a8138fc79c736026
driver: pcov (loaded, enabled per run)
framework: plain
no baseline yet
$ vendor/bin/phpunit-replay record ............................S...... 35 / 35 (100%) OK, but some tests were skipped! Tests: 35, Assertions: 61, Skipped: 1. Replay ● recorded 35 tests in 7 test files · 12 source files · 18 edges · graph.json 6 KB · baseline main@0742ab4 · 0s
$ vendor/bin/phpunit-replay Replay ✓ 0 executed (0 affected, 0 uncached) · 35 replayed · 0 quarantined · baseline main@0742ab4
Nothing changed, so nothing runs: the whole pass finishes in about 180 ms, PHP bootstrap included.
Now edit src/Pricing.php and ask what that would affect, without running anything:
$ vendor/bin/phpunit-replay --explain --dry-run
tests/CartTest.php ← PhpEdge src/Pricing.php
tests/PricingTest.php ← PhpEdge src/Pricing.php
Replay 2 test files would run (2 affected, 0 uncached, 0 quarantined), 33 tests would replay
$ vendor/bin/phpunit-replay Replay ✓ 6 executed (6 affected, 0 uncached) · 29 replayed · 0 quarantined · baseline main@0742ab4
Only the two test files with a recorded edge to Pricing.php ran; everything else replayed.
Two ways to run it
The wrapper (filtered mode) — the default, zero changes to your tests
vendor/bin/phpunit-replay resolves the affected test files (see
How selection works), then writes .phpunit-replay.xml next to your real
configuration: your phpunit.xml/phpunit.xml.dist verbatim, except <testsuites> is replaced by
a single suite listing one <file> per test file that must actually run (<source>, <php>,
<extensions>, bootstrap all kept as-is; ReplayExtension is injected as a bootstrap extension if
not already registered). PHPUnit then runs against that generated file with --no-coverage — the
raw pcov/Xdebug driver, not PHPUnit's own coverage, is what records edges — and the generated file
is deleted once the run finishes (PHPUNIT_REPLAY_KEEP_RUN=1 keeps it for inspection).
Add .phpunit-replay.xml to your .gitignore.
Passing a PHPUnit selection option yourself — --filter, --group, --exclude-group,
--testsuite, an explicit path, --covers, --uses — disables the selection logic for that run:
PHPUnit runs exactly what you asked for, and only the results of the tests that ran are refreshed.
The in-process trait — for when PHPUnit must see the whole suite
For an IDE that launches phpunit directly, --coverage-html, or simply not wanting the wrapper
in the loop. Extend Manuglopez\Replay\PHPUnit\ReplayableTestCase instead of
PHPUnit\Framework\TestCase, or add the trait to your own base class:
abstract class TestCase extends \PHPUnit\Framework\TestCase { use \Manuglopez\Replay\PHPUnit\Replayable; protected function setUp(): void { parent::setUp(); if ($this->isReplaying()) { return; // optional: skip expensive boot work too, not just the test body } // ...boot the app, RefreshDatabase, etc. } }
and register the extension in phpunit.xml:
<extensions> <bootstrap class="Manuglopez\Replay\PHPUnit\ReplayExtension"> <parameter name="mode" value="auto"/> <!-- auto|record|replay|off --> </bootstrap> </extensions>
setUp() always runs, for every test, replayed or not — only the code guarded behind
isReplaying() (and only if you call it after parent::setUp()) is skipped; the trait hooks
the test method itself, never setUp(). On PHPUnit 12 it overrides the
invokeTestMethod() hook cleanly; on PHPUnit 11.5, which has no such hook, a #[Before]
method swaps the test's private method name through reflection instead (see
docs/spikes/in-process-replay.md for the two mechanisms verified side by side).
Never replayed, in either mode: a #[Depends] provider for another test (a replayed provider
would hand its dependents a null return value), a cached failure or error (always reruns), a
test unknown to the graph (new), a test marked #[NotCacheable] or matched by never_cache, and a
quarantined test (see Keeping the cache honest).
Commands
run is the default, so vendor/bin/phpunit-replay and vendor/bin/phpunit-replay run are the
same thing. Anything after a literal --, or the first token phpunit-replay doesn't recognise, is
forwarded to vendor/bin/phpunit untouched.
| Command | Options | What it does |
|---|---|---|
run (default) |
--fresh --no-remote --explain --dry-run --log-junit=FILE --allow-ci-baseline --parallel/-p[=N] [-- <phpunit args>] |
Runs only what's affected, replays the rest. See below for each option. |
record |
--fresh --parallel/-p[=N] |
Runs the full suite unconditionally and records a fresh baseline. What CI runs on the default branch after a merge. |
verify |
[-- <phpunit args>] |
Runs the full suite in record mode and compares every result against what a replay pass would have served — the divergence metric (see Keeping the cache honest). |
status |
— | Prints the cached graph: root, branch, state dir, coverage driver, framework, file/edge/table counts, graph.json size, per-branch results, fingerprint drift, quarantine, not-cacheable count, remote, lifetime divergences. |
explain <path> |
— | Prints which recorded test files a change to <path> would affect, and by which rule — without running anything. |
prune |
--flaky --branches --all --remote --keep-months=N --squash |
Drops stale state without touching a live pass. See below. |
push |
--graph |
Publishes cached objects (and, with --graph, the branch baseline) to the configured remote. |
pull |
— | Fetches the branch baseline from the remote and stores it locally. |
baseline-path |
— | Prints the resolved state directory and nothing else — for CI to know what to archive. |
run options in detail:
| Option | Effect |
|---|---|
--fresh |
Ignore any cached baseline and record a fresh one. |
--no-remote |
Never contact a configured remote cache for this run. |
--explain |
Print which rule selected each test file, and why (same table as explain <path>). |
--dry-run |
Print what would run without running it; implies --explain. |
--log-junit=FILE |
Write a merged JUnit report to FILE — real results plus replayed ones, replayed entries marked <property name="replayed" value="true"/>. |
--allow-ci-baseline |
Let a run detected as CI (CI env var set) publish a branch baseline; without it, a CI run never updates the stored baseline. |
--parallel/-p[=N] |
Run through Paratest instead of a single phpunit process. |
prune options: --flaky clears the quarantine; --branches removes baselines for branches git
no longer knows; --all deletes the whole state directory's contents; with no flag, prunes deleted
test files plus --branches. --remote switches to garbage-collecting the remote cache instead
of the local graph: it deletes object shards older than --keep-months (default 3) except objects
still referenced by a branch baseline, and --squash (git backend only) rewrites the remote
branch as a single orphan commit.
Environment variables — always win over phpunit-replay.php:
| Variable | Effect |
|---|---|
PHPUNIT_REPLAY=0 |
Disables phpunit-replay entirely, even with the extension registered in phpunit.xml. |
PHPUNIT_REPLAY_DEBUG=1 |
Prints every selection decision to stderr. |
PHPUNIT_REPLAY_STATE_DIR |
Overrides state_dir. |
PHPUNIT_REPLAY_REMOTE / PHPUNIT_REPLAY_REMOTE_TOKEN |
Override remote / remote_token. |
PHPUNIT_REPLAY_REMOTE_PUSH |
Overrides remote_push (objects|all|off). |
PHPUNIT_REPLAY_BASELINE_BRANCHES |
Comma-separated, overrides baseline_branches. |
PHPUNIT_REPLAY_DEFAULT_BRANCH |
Overrides default_branch. |
PHPUNIT_REPLAY_MODE |
Overrides the extension mode (also accepts the internal record-subset/results-only values the wrapper itself uses). |
PHPUNIT_REPLAY_KEEP_RUN=1 |
Keeps the generated .phpunit-replay.xml and the run's partial directory for inspection. |
PHPUNIT_REPLAY_LEGACY_HOOK=1 |
Forces in-process mode's PHPUnit 11.5 reflection fallback even on PHPUnit 12. |
CI |
Detected automatically; gates whether a run may publish a branch baseline (see --allow-ci-baseline). |
A few more PHPUNIT_REPLAY_* variables exist purely for internal wrapper-to-extension
communication (run id, resolved root/binary path); you shouldn't need to set them by hand.
How selection works
Changed files are computed by diffing against the recorded baseline sha (it must be an
ancestor of HEAD, or a fresh recording is forced), unioned with the current working-tree status
(staged, unstaged, untracked — minus anything git check-ignore would exclude). Two filters then
narrow that set:
- Content-hash filter — a file is dropped if its normalized content hash is unchanged from the
baseline commit: comment/whitespace-only edits to
.php(tokenizer-based), Blade comments/whitespace, and JS/TS/Vue/Svelte comment/whitespace edits are all ignored this way. - Last-run snapshot — a dirty file already accounted for in the previous run is dropped again (touching the same uncommitted change twice doesn't re-run its tests), but a reverted file is picked back up.
Selection rules run in order, each consuming what earlier rules didn't claim (the Laravel-only ones are no-ops on a non-Laravel project — see Laravel):
| # | Rule | Triggers on | Effect |
|---|---|---|---|
| 1 | MigrationRule (Laravel) |
a changed database/migrations/**/*.php file |
tables it creates/alters intersected against every test file's recorded tables |
| 2 | PhpEdgeRule |
a changed (or deleted) file with an id in the graph | every test file whose recorded edges include it |
| 3 | TestFileRule |
a changed file that is itself a test file (per <testsuites>) and still exists |
affects itself |
| 4 | SiblingRule (Laravel) |
a new/unknown .php file under a provider/listener/event/observer/policy/console-command/factory/seeder directory |
tests with an edge to another file in the same directory |
| 5 | BladeRule (Laravel) |
a changed .blade.php unknown to the graph |
walked through static references (@include, @extends, view(), <x-...>) up to a Blade file the graph knows; tests with an edge to that ancestor |
| 6 | WatchRule |
whatever is left, unknown to the graph | glob → test directory patterns: generic defaults (.env*, phpunit.xml*, docker-compose*.y*ml, fixtures/snapshots), framework defaults when detected (see below), and your own watch config |
On top of the rules, two more categories always run: unknown test files (on disk, matching
PHPUnit's test-path rules, but no recorded edges — new tests) and any cached result whose status
must be re-run: a failure or error always reruns; a risky/warning/notice/deprecation/
incomplete/skipped result reruns only if your PHPUnit configuration's --fail-on-* /
displayDetailsOn* settings would actually surface it.
Built-in WatchRule defaults by detected framework:
| Framework (detected by) | Patterns |
|---|---|
| Generic (always) | .env*, phpunit.xml*, docker-compose*.y*ml, tests/**/Fixtures/**, tests/**/__snapshots__/** |
Laravel (artisan exists) |
config/**, routes/**, database/migrations/**, resources/views/**, lang/**, resources/lang/**, app/** !*.php, bootstrap/*.php |
Symfony (config/bundles.php exists) |
config/**, migrations/**, templates/**, translations/** |
A fingerprint guards against incompatible baselines: its structural half (composer.lock,
phpunit.xml(.dist), phpunit-replay.php, the cache schema version) changing discards the whole
graph and forces a fresh recording; its environmental half (PHP MAJOR.MINOR, coverage driver,
OS family) changing keeps the edges but discards cached results, which can't be trusted across a
PHP version or driver change.
Baselines are kept per branch. On a branch with no baseline of its own, phpunit-replay walks
an ordered list of candidates (baseline_branches, or the single default_branch as shorthand),
keeps only those whose recorded sha is an ancestor of HEAD, and picks whichever is fewest files
different from the current tree — the setup git-flow teams want: a feature branch cut from
develop inherits develop's baseline, a hotfix cut from main inherits main's, instead of
everything falling back to one shared default. status and --explain report which baseline was
chosen and why.
One rule is deliberate and worth internalizing: a file no test ever executed affects nothing. A docs change, an unused helper, dead code — if no recorded edge points at it, it cannot change any test's outcome, so nothing runs.
Configuration
An optional phpunit-replay.php at the project root, returning an array (every key optional):
<?php // phpunit-replay.php return [ 'state_dir' => null, // null = ~/.phpunit-replay/<project-key> 'remote' => null, // null | 'file:///mnt/replay-cache' | 'https://cache.example.com/replay/' | 'git@github.com:org/project-replay-cache.git' 'remote_token' => null, // bearer token for the HTTP backend 'remote_push' => 'objects', // 'objects' (this machine's results only) | 'all' (also publish branch baselines — CI only) | 'off' (pull only) 'remote_branch' => 'main', // git backend: which branch of the cache repo to use 'remote_refresh_seconds' => 300, // git backend: how often the local mirror re-fetches 'remote_timeout' => 60, // git backend: total time budget for a push before giving up 'default_branch' => null, // null = autodetect (origin/HEAD, init.defaultBranch, main/master) 'baseline_branches' => [], // ordered nearest-baseline candidates for git-flow branching; [] = [default_branch] 'watch' => [], // extra glob => test directory/file mappings, merged with the built-in defaults 'never_cache' => [], // globs of test files that always run for real (see Keeping the cache honest) 'quarantine_release_after' => 20, // stable passes needed to leave automatic quarantine 'laravel' => 'auto', // 'auto' | 'on' | 'off' 'junit_merge' => true, // merge cached results into --log-junit output 'mode' => 'auto', // extension mode override; leave at 'auto' unless you know why not 'hermeticity_heuristics' => false, // reserved for a future heuristic (flagging suspicious tests in `status`); not implemented — leave false ];
Environment variables always win over this file — see the table in Commands.
Keeping the cache honest
Replaying a stale or wrong result would be worse than not caching at all, so phpunit-replay gives you three ways to keep a test from ever being served stale, plus one that happens automatically:
-
#[NotCacheable(reason: '...')]on a test class or method — read by reflection while recording, persisted in the graph. The test always executes for real, even when its content key is unchanged (use Manuglopez\Replay\Attributes\NotCacheable;). -
never_cacheglobs inphpunit-replay.php— any test file matching one always runs (e.g.tests/Browser/**, or tests that hit real external services). -
Automatic quarantine — whenever new results are merged, a test whose content key is unchanged but whose result class flipped (pass↔fail, pass↔error) is recorded in
flaky.jsonand forced to run every subsequent pass, until it's released viaprune --flakyor automatically afterquarantine_release_after(default 20) consecutive stable passes. A cached failure recovering to a pass is not a flip — that's the normal heal path. -
verifyis the objective metric for all of this: it runs the full suite in record mode and compares every result against what a normal replay pass would have served. A mismatch is a divergence — logged, quarantined automatically, and reflected in the summary:Verify ✓ 1240 tests · 1198 would replay · 0 divergences (lifetime: 2 in 143 runs)
status shows the current quarantine list (with flip counts) and the lifetime divergence count —
the number to watch when deciding whether a fast run lane is trustworthy enough to become a PR
gate on its own (see CI in two lanes).
hermeticity_heuristics (off by default) is reserved for a future heuristic that would flag
suspicious-looking tests (unfaked Carbon/Faker, HTTP without Http::fake()) in status without
quarantining them — not implemented in this build; leave it false.
Sharing the cache with your team
By default every machine — your laptop, a coworker's, each CI runner — keeps its own local
graph.json, so each re-records from scratch the first time it sees a given commit. Configuring a
remote turns that into a content-addressed object store any machine can push results to and
pull results from, so work one machine already did is inherited instead of repeated.
| Local only (default) | Shared folder (file://) |
HTTP (S3/MinIO, WebDAV) | Dedicated git repository | CI artifacts | |
|---|---|---|---|---|---|
| Prerequisites | none | a mounted path all machines reach | an HTTP endpoint with GET/PUT/HEAD | an empty git repo + CI deploy key | none — built into GitHub Actions |
| Best for | solo projects, evaluating the package | one office/VPN | teams already on object storage | teams with git but no object storage | GitHub-only, zero extra infra |
| Failure behaviour | n/a | warning + local-only run | same | same | cache miss → full record for that job |
See docs/sharing-the-cache.md for setup steps for each backend,
baseline_branches for git-flow, and troubleshooting. A remote that's unreachable or misconfigured
always degrades to a warning on stderr and a local-only pass — it can never break a test run.
A brand-new checkout, once a baseline has been published:
$ vendor/bin/phpunit-replay
Replay ✓ 0 executed (0 affected, 0 uncached) · 35 replayed (35 from remote) · 0 quarantined · baseline main@a1b2c3d
(35 from remote) means every one of those results came from the shared cache, not a local
recording — a machine that has never run this suite still gets a near-instant first pass.
remote_push governs who publishes what: developer machines and PR jobs default to objects
(only their own test-file results, keyed by content — safe to publish from anywhere, never
conflicts); only the CI job that owns the branch baseline (remote_push: 'all', typically gated by
--allow-ci-baseline) publishes graph/**, which is what everyone else's cold start reads.
CI in two lanes
The recommendation is the same one Pest gives for its own TIA: PR CI keeps running the full,
unfiltered suite as the actual merge gate — phpunit-replay verify does this while also
comparing every result against the cache, which is what keeps the baseline trustworthy and feeds
the divergence metric. A fast, optional lane runs phpunit-replay run for quick feedback in
minutes. A separate workflow records the baseline after each merge to the default branch
(run --allow-ci-baseline or record --fresh, then push --graph).
fast (every PR): vendor/bin/phpunit-replay run — quick feedback, not the gate
full (every PR): vendor/bin/phpunit-replay verify — the actual merge gate
baseline (on push to main/develop): record/run + push --graph
Requirements: a checkout with enough history that the baseline sha is an ancestor of HEAD
(fetch-depth: 0 on GitHub Actions); pcov or Xdebug on the runner; a configured remote (or the
baseline-path + actions/cache alternative) so state carries between jobs. Working examples for
all three jobs, plus the monthly cache GC job, are in
.github/workflows/examples/
(ci.yml, tia-baseline.yml, tia-gc.yml) — copy them into your own project's
.github/workflows/.
Laravel
Autodetected: enabled when <root>/artisan exists and the laravel config key isn't off. The
package has no illuminate/* dependency itself — Laravel is reached through class_exists(),
string class names, and duck-typed calls.
What gets tracked while recording, once the app has booted for a test file:
- Tables — a query listener extracts the table name(s) touched by every
select|insert|update|delete|with|replacequery and links them to the test file (migrations,sqlite_*,pg_*,information_schema*excluded). - Blade views — a view composer on
'*'links every rendered view's path as a source dependency of the test file, exactly like a PHP file it directly touched. - Migration-aware tests — every test file using
RefreshDatabase,DatabaseMigrations, orDatabaseTransactionsis additionally widened, when the graph is written, to cover every table any migration underdatabase/migrations/creates — conservative by design.
The package's own laravel-lite fixture (4 Feature tests, 3 migrations, 2 Blade views)
demonstrates the effect end to end:
| Change | Result |
|---|---|
Add a column to the comments migration |
3 executed (3 affected) · 1 replayed — every test using RefreshDatabase; HomePageTest, which never touches the database, replays |
Edit welcome.blade.php |
1 executed · 3 replayed — only HomePageTest, the one test that renders it |
Parallel
Add --parallel/-p to run or record to run the same filtered configuration through
Paratest instead of a single phpunit process:
phpunit-replay --parallel # Paratest's own auto-detected process count phpunit-replay -p 4 # 4 worker processes phpunit-replay record -p 4 # a full parallel recording pass
Paratest is an optional require-dev dependency (brianium/paratest). When --parallel/-p is
given but vendor/bin/paratest isn't installed, phpunit-replay warns on stderr and falls back to a
sequential PHPUnit run rather than failing. Each worker writes its own partial results; they're
merged back together before updating the graph (edges by union, results last-write-wins), so the
summary line is the same regardless of process count. The coverage driver's ini flags travel to
Paratest's workers via --passthru-php.
Coverage reports with replay
Pass --coverage-php=FILE through to PHPUnit as usual. When phpunit-replay records a test file
with coverage active, it stores that file's own coverage slice (<state dir>/coverage/<k>.cov,
keyed by content). On a later pass, PHPUnit's own coverage — from whatever actually executed — is
merged with the stored snapshots of everything that replayed, so --coverage-php reflects the
whole suite, not just what ran:
Lines: 97.59% (81/83) # 0 executed this pass — the figure came entirely from snapshots
Limitation: a snapshot only exists for a test file that was recorded with --coverage-php
active. A test that's risky, incomplete, skipped, or otherwise didn't produce a real coverage
sample at record time carries no piggyback coverage into a merged report — it simply contributes
nothing, the same as if it had never run. --coverage-html/--coverage-clover are produced by the
user from the merged .php report, same as any other PHPUnit coverage workflow.
Comparison
Being specific about what each tool actually does, rather than what it aims to do:
| Pest 5 TIA | jasonmccreary/phpunit-tia | gosuperscript/phpunit-tia | phpunit-replay | |
|---|---|---|---|---|
| Runner | Pest only (aborts on plain PHPUnit test classes) | PHPUnit | PHPUnit | PHPUnit 11.5+ and 12, no Pest |
| Unaffected tests | Synthetic pass, real assertion count | Skipped | Skipped | Filtered mode: never loaded at all. In-process mode: synthetic pass with the real assertion count |
| Complete summary/JUnit | Yes | No — skipped tests lose their assertion count | No | Yes — cached results merge into the summary and, on request, into JUnit |
| Cosmetic-only changes ignored | Yes (tokenizer) | Partial | Partial | Yes (tokenizer-based content hash) |
| Per-branch baselines | Yes | No | No | Yes, plus nearest-baseline resolution for git-flow (baseline_branches) |
| Remote cache | GitHub Actions artifact via gh |
No | No | Content-addressed, backend-agnostic: filesystem, HTTP/S3/MinIO, or a dedicated git repository |
| Non-hermetic test handling | No detection | No detection | No detection | #[NotCacheable], never_cache globs, automatic quarantine on a pass/fail flip, verify's divergence metric |
| Laravel awareness | Yes | No | No | Yes, optional, autodetected (tables, Blade, migration-aware tests) |
| Parallel | Yes, built in | No | No | Yes, via Paratest (--parallel/-p) |
The distinction that matters most: an unaffected test in either phpunit-tia package is reported
as skipped — its assertion count is gone, and depending on your PHPUnit configuration a skip
can even fail the build via --fail-on-skipped. In phpunit-replay it either never enters the run
at all (filtered mode) or is reported as a pass with the exact assertion count it produced last
time (in-process mode) — the summary reflects what really happened, not a gap papered over.
No code from jasonmccreary/phpunit-tia or gosuperscript/phpunit-tia was used. Roughly 60% of
Pest's own TIA engine — the framework-agnostic part — was ported by copy under its MIT license;
see Attribution.
How other ecosystems do it, and where this sits
Test/task selection and caching by recorded dependency is not a new idea — most language and build ecosystems have their own version of it:
| Ecosystem | Approach |
|---|---|
Go's go test |
Result cache keyed by a hash of the test binary and its inputs; a cache hit prints (cached) instead of re-running |
| Bazel / Buck2 | Declared build/test graph plus a remote action cache keyed by action inputs |
| Nx / Turborepo | Task input hashing with a shareable remote cache, at the JS/TS monorepo task level |
Jest --onlyChanged / Vitest |
Static import-graph analysis from files git reports as changed |
| pytest-testmon | Coverage-based selection, but at line/block granularity, not file granularity |
| Ekstazi | Regression Test Selection for Java/Maven, via recorded class-level dependencies |
| Datadog Intelligent Test Runner | Coverage-based, skips tests unaffected by the diff |
| Gradle Predictive Test Selection / Launchable | ML-ranked test selection from historical failure data |
phpunit-replay sits closest to testmon and Ekstazi: coverage-based regression test
selection from a recorded dependency graph, not a static import guess. Its content-addressed
sharing is the same idea as Go's and Bazel's remote caches — a result keyed by what actually went
into producing it, reusable by any machine with the same inputs. Its replay-as-pass behavior is
the PHPUnit analogue of Go's (cached) marker: a result reported honestly as "this is what already
happened," not hidden. And its two-lane CI recommendation mirrors Gradle Predictive Test
Selection's own guidance — a fast advisory lane plus a full lane that remains the actual gate. The
one axis where phpunit-replay is intentionally coarser than testmon is granularity: file-level,
not block-level — see Known limitations.
Known limitations
- Edges are file-level, not method- or line-level. Any change to a source file re-runs every test file whose recorded edges include it, even if the change touched an unrelated function.
- In-process mode still runs
setUp()for every test — only the code guarded behindisReplaying(), called afterparent::setUp(), is skipped; a replayed test still pays for anything outside that guard. #[Depends]providers always execute for real, in-process mode included: a replayed provider would hand its dependents anullreturn value, so such a test is never replayable.- Non-hermetic tests need an explicit marker. phpunit-replay cannot detect on its own that a
test's result depends on something outside its recorded source edges (the system clock, an
external API); mark it
#[NotCacheable]or anever_cacheglob, or let automatic quarantine catch it after its first pass/fail flip. - The HTTP remote backend has no listing endpoint, so
prune --remoteneeds the filesystem or git backend to enumerate and garbage-collect object shards. - Coverage snapshots exist only for test files recorded with
--coverage-phpactive — a merged report has no data for a test file whose baseline was recorded without it. - A rebase invalidates a sha-based baseline (the recorded commit is no longer an ancestor of
HEAD, forcing a fresh recording), but content-addressed replay still works: a test file whose actual content is unchanged replays by its content key regardless of the rebase.
Trying it on your project
- From the real project (not this repository), to test a local checkout instead of a Packagist
release:
composer config repositories.replay path ../phpunit-replay && composer require --dev manuglopez/phpunit-replay:@dev. vendor/bin/phpunit-replay statusshould say there is no baseline yet, and show the detected coverage driver, git root, default branch, and test framework.vendor/bin/phpunit-replay recordruns the whole suite once and ends with the recording summary: test files, source files, edges,graph.jsonsize, and time taken.- Run
vendor/bin/phpunit-replayagain with nothing changed: 0 executed, everything replayed, finishing in under 2 seconds plus PHP's own bootstrap time. - Touch one class, then run
vendor/bin/phpunit-replay --explain: it lists which test files are affected, and by which rule, without running anything. vendor/bin/phpunit-replay verifyruns the whole suite again in record mode and reports how many results diverge from what a normal replay pass would have served —0 divergencesif nothing has drifted.- If something looks wrong:
--freshforces a clean recording,statusshows what state is currently stored, andPHPUNIT_REPLAY_DEBUG=1 vendor/bin/phpunit-replayprints every selection decision to stderr.
Attribution
Portions of this package are derived from Pest
(© Nuno Maduro, MIT license) — specifically the framework-agnostic parts of its Test Impact
Analysis engine. The full original license text is in
LICENSE-PEST.md, and every ported file carries an @see docblock pointing at
its exact origin (file and commit). This project is not affiliated with, endorsed by, or
officially connected to Pest or its authors.
License
MIT. See LICENSE. © Manuel González.