Pest 5 and PHPUnit 13: What Breaks When You Upgrade (and What Fails Silently)
Table of Contents
Pest 5 shipped at the end of July 2026, built directly on top of PHPUnit 13, and requires PHP 8.4 as a hard minimum. Pest itself introduces no API-level breaking changes — the pain is entirely inherited from PHPUnit 13’s stricter mocking rules. The part that actually hurts teams is not a fatal error: it’s that PHPUnit 13 turns several common mocking patterns into deprecation warnings instead of failures, so a suite can go fully green while quietly no longer verifying what you think it verifies. If you’re on Laravel 12, there’s a second, blunter problem: the official Pest Laravel plugin for v5 currently requires Laravel 13.23 or newer, so the plugin itself won’t install.
Where are you coming from?
The upgrade effort isn’t uniform. It depends on how many PHPUnit majors you’re jumping in one move.
- Pest 4 (PHPUnit 12) → Pest 5 (PHPUnit 13). This is the intended, smallest jump. Pest 4 already required PHP 8.3, so the only hard floor to raise is PHP 8.3 → 8.4. You’re absorbing one wave of PHPUnit deprecations-to-removals (12 → 13).
- Pest 3 or earlier (PHPUnit 11 or older) → Pest 5. You’re absorbing two stacked waves at once: PHPUnit 11 → 12 already removed docblock-annotation metadata (you need PHP 8 attributes:
#[Test],#[DataProvider], etc.) and mock objects for abstract classes/traits, on top of everything PHPUnit 13 changes below. Skipping straight from 3 to 5 in onecomposer updateis how a 20-minute upgrade becomes a two-day one — go through 4 first, or at least test the 12 jump in isolation. - Raw PHPUnit, no Pest, on PHPUnit 12.5. PHPUnit’s own upgrade rule is explicit: don’t move to 13 if your suite doesn’t already run clean on 12.5. If
phpunit --display-deprecationsshows anything, fix that first, in place, before touching the Pest or PHPUnit version constraint.
The floor you can’t negotiate: PHP 8.4
Pest 5 requires PHP 8.4.0 or greater, full stop. There’s no “works in practice on 8.3” escape hatch like some Laravel version bumps have. If your team is still on PHP 8.1, 8.2, or 8.3, that’s infrastructure work that happens before you touch composer.json for Pest — not alongside it.
php -v # need 8.4.0 or higher
Do the PHP bump as its own deploy, verified green on the old test stack, before you also swap the test stack. Otherwise a red CI run after the upgrade tells you nothing about which of the two changes caused it.
Breaking changes, ranked by what actually hurts
1. Laravel 12 can’t use the Pest 5 Laravel plugin (blocking, not silent)
This is the one nobody mentions in the “it’s just a one-line bump” framing, and it’s the first thing that will stop a Laravel shop cold. pestphp/pest-plugin-laravel v5.0.1 declares:
{
"require": {
"php": "^8.4",
"laravel/framework": "^13.23.0",
"pestphp/pest": "^5.0.1"
}
}
If your app is on Laravel 12, composer update will refuse to resolve pest-plugin-laravel ^5.0 — Composer will report a conflict, not a mysterious test failure. The fix isn’t a Pest problem: you need Laravel 13.23+ before Pest 5’s Laravel plugin is installable at all. If you’re mid-upgrade on both fronts, sequence it — Laravel first, Pest second — or you’ll spend an afternoon debugging a dependency graph that was never going to resolve.
2. any() and with*() without expects() — the suite that passes but stops verifying (silent)
This is the change that matters most, because nothing in your CI output tells you it happened. PHPUnit 13 hard-deprecates two patterns that were previously the default, lazy way to configure a mock:
// Before: works, silently means "any number of calls, don't verify order"
$mock->method('handle')->with($this->equalTo($payload));
// PHPUnit 13: emits a deprecation, but still runs and still "passes"
Deprecations in PHPUnit are warnings, not failures, unless you explicitly configure failOnDeprecation (or PHPUnit’s own failOnPhpunitDeprecation) in your suite. Which means the realistic failure mode isn’t “upgrade breaks CI on day one” — it’s “CI stays green for weeks while accumulating dozens of deprecation warnings nobody reads, and then either someone turns on strict deprecation failing, or PHPUnit 14 removes the pattern outright and the suite goes red all at once, on a date you don’t control.”
The fix is to make the intent explicit instead of relying on the implicit default:
// If you actually want to verify the call happens:
$mock->expects($this->once())->method('handle')->with($this->equalTo($payload));
// If you don't care whether it's called — use a stub, not a mock:
$stub = $this->createStub(HandlerInterface::class);
$stub->method('handle')->willReturn($result);
The any() invocation-count matcher itself is hard-deprecated for the same reason: pairing expects($this->any()) with a mock is a contradiction — mocks exist to verify interactions, and any() means “I don’t care if this happens.” PHPUnit’s own maintainers frame it plainly: if you don’t want to verify anything, you wanted a stub.
3. Laravel’s createPartialMock() collides with rule #2 (silent, Laravel-specific)
This is the same deprecation as above, but worth calling out on its own because it hits a helper most Laravel test suites use without thinking about it. Laravel’s testing trait wraps PHPUnit’s createPartialMock(), and that wrapper’s internal implementation triggers the “with*() without expects()” deprecation on PHPUnit 13 even when your test code looks fine:
// Triggers the deprecation on PHPUnit 13, even though this looks innocent
$class = $this->createPartialMock(NightlyCommand::class, ['runSequenceOfCommands']);
// Fixed: bypass the wrapper, use PHPUnit's builder directly
$class = $this->getMockBuilder(NightlyCommand::class)
->onlyMethods(['runSequenceOfCommands'])
->getMock();
Grep for it before you upgrade, because the deprecation count can be large if the pattern is common in your suite:
grep -rln "createPartialMock" tests/
4. Real removals — these do fail, immediately, no ambiguity
Unlike the two above, these were already hard-deprecated in PHPUnit 12 and are simply gone in 13. They throw a fatal Error, not a warning, so they surface on the first run — which is actually the easy category to deal with:
// Removed in PHPUnit 13 — fatal error, not a deprecation
Assert::isType('string', $value);
$this->assertContainsOnly('string', $collection);
$this->assertNotContainsOnly('int', $collection);
// Replace containsOnly()-style checks with the new array assertions,
// or fall back to explicit type checks
self::assertContainsOnlyInstancesOf(string::class, $collection); // if applicable
Also removed: the --dont-report-useless-tests CLI flag, Configuration::includeTestSuite() / excludeTestSuite(), #[CoversNothing] on individual test methods (class-level only now), the #[RunClassInSeparateProcess] attribute, and support for version-constraint strings without an explicit comparison operator. None of these are common in a typical Laravel test suite, but if you maintain a package with custom PHPUnit extensions or CI tooling that shells out to phpunit directly, check your flags.
Coming from withConsecutive() specifically: that one is old news — it was removed back in PHPUnit 10 — but PHPUnit 13 finally ships proper replacements instead of leaving people writing awkward call-count workarounds:
// New in PHPUnit 13
$mock->expects($this->once())
->method('handle')
->withParameterSetsInOrder(['first'], ['second']);
$mock->expects($this->exactly(2))
->method('handle')
->withParameterSetsInAnyOrder(['first'], ['second']);
5. Test Impact Analysis needs a coverage driver, or it just doesn’t run (silent)
Pest 5’s headline feature — the Tia Engine, which claims to shrink a 10-minute Laravel suite to about 4 seconds on subsequent runs by only re-running tests affected by your diff — needs PCOV or Xdebug installed to record its baseline dependency graph. Without one, Pest doesn’t error and doesn’t warn loudly; TIA simply never activates, and your suite quietly keeps running at full length every time. If you enabled TIA expecting the speedup and nothing changed, check for a coverage driver before assuming the feature is broken:
php -m | grep -iE "pcov|xdebug"
For a team-wide setup, the sane pattern is to have CI record the baseline once per merge to main (where a coverage driver is cheap to enable) and have local machines consume that cached graph, rather than every developer running a coverage-instrumented baseline locally.
6. No config-file changes — genuinely nothing to do here
Worth stating explicitly because people expect it: the Pest 5 upgrade guide documents no changes to phpunit.xml or any Pest configuration file. If your suite runs clean under PHPUnit 13’s rules, the version bump in composer.json is the entire migration. That’s rare enough in this ecosystem that it’s worth not overthinking.
When should you upgrade?
- You’re on Pest 4 / PHPUnit 12, PHP 8.4 is already your baseline, and
phpunit --display-deprecationsis clean. Upgrade now. This is close to the “2 minutes” the docs advertise, and TIA alone is worth it if your suite has grown past a couple thousand tests. - You’re on Pest 4 but still on PHP 8.3. Raise PHP first, verify the existing suite is still green and deprecation-free on 8.4, then bump Pest as a separate step.
- You’re on Pest 3 or plain PHPUnit 11 or older. Don’t skip straight to 5. Go through the PHPUnit 12 wave first (attributes instead of docblocks, no more abstract/trait mocks), confirm the suite is clean, then take the 12 → 13 jump on its own.
- You’re a Laravel shop still on Laravel 12. Pest 5’s Laravel plugin is out of reach until you’re on Laravel 13.23+. Either upgrade Laravel first, or stay on
pest-plugin-laravel ^4.0with Pest 4 until you do — don’t let Composer’s dependency resolver make that decision for you mid-upgrade. - Anyone with heavy mock usage in the suite, regardless of version. Run the deprecation grep before you touch
composer.jsonat all. The cost of this upgrade isn’t the version bump — it’s finding out how many of your mocks were only “passing” because nothing was actually being verified.
Further reading:
- What breaks when you upgrade to Laravel 13
- What breaks when you upgrade to PHP 8.6
- How to upgrade Laravel 9 to Laravel 10