5 min read

PHPUnit 13: Sealed Mocks, Smarter Array Assertions, and a Real withConsecutive() Replacement

PHPUnit 13 adds sealed test doubles, eight precise array assertions, and a true withConsecutive() replacement. Here is what PHP developers should know.

Featured image for "PHPUnit 13: Sealed Mocks, Smarter Array Assertions, and a Real withConsecutive() Replacement"

PHPUnit 13 shipped on February 6, 2026, and with 13.1 already out and 13.2 landing this month, the release has had enough time in the wild to judge it fairly. The verdict: this is the most opinionated PHPUnit release in years, and almost every opinion it holds is one your test suite will benefit from. Sealed test doubles, eight new array assertions, and an actual answer to the withConsecutive() problem that has annoyed upgraders since PHPUnit 10.

Before you reach for composer require, know the floor: PHPUnit 13 requires PHP 8.4 or later. That is consistent with where the language is. As of February 2026, PHP 8.4 and 8.5 are the only actively supported versions. If you are still on 8.3, PHPUnit 12 receives bug fixes until February 2027, so you have runway.

Sealed Test Doubles: The Headline Feature

If you have ever had a test pass when it should have failed because an unconfigured mock method quietly returned null, sealed test doubles are for you.

Calling the new seal() method on a mock or stub finalizes its configuration. After sealing, any further expects() or method() calls throw. More importantly, on mock objects, sealing flips the default behavior for every method you did not configure: instead of silently returning null, an unexpected invocation fails the test.

<?php

use PHPUnit\Framework\TestCase;

final class InvoiceProcessorTest extends TestCase
{
    public function testSendsReceiptAfterProcessing(): void
    {
        $mailer = $this->createMock(Mailer::class);

        $mailer->expects($this->once())
               ->method('sendReceipt')
               ->with($this->isInstanceOf(Invoice::class));

        // Configuration is done. Lock it.
        $mailer->seal();

        $processor = new InvoiceProcessor($mailer);
        $processor->process(new Invoice(total: 4999));

        // If process() also called $mailer->sendReminder(),
        // the old behavior was: nothing happens, test passes.
        // Sealed behavior: test fails. As it should.
    }
}

This catches a whole class of bugs that traditional mocks let slide: the system under test interacting with a dependency in ways your test never declared. I have lost real debugging hours to exactly this, where a refactor added a second call to a mocked collaborator and every test stayed green.

You can enforce sealing suite-wide with the requireSealedMockObjects option in phpunit.xml:

<phpunit requireSealedMockObjects="true">
    <!-- any unsealed mock now fails the test -->
</phpunit>

The feature is purely additive. Existing suites run unchanged, and you opt in file by file or globally when you are ready. For a legacy codebase, my suggestion is to enable the XML flag on one test directory at a time, the same incremental approach that works for PHPStan baseline reduction.

Eight Array Assertions That Say What They Mean

Every PHP developer has written this dance: sort() both arrays, then assertSame(). Or array_values() both sides because keys did not matter. PHPUnit 13 replaces those workarounds with assertions that encode intent in the method name along three dimensions: strict versus loose comparison, keys considered versus ignored, and order significant versus ignored.

The full set: assertArraysAreIdentical(), assertArraysAreIdenticalIgnoringOrder(), assertArraysHaveIdenticalValues(), assertArraysHaveIdenticalValuesIgnoringOrder(), assertArraysAreEqual(), assertArraysAreEqualIgnoringOrder(), assertArraysHaveEqualValues(), and assertArraysHaveEqualValuesIgnoringOrder().

In practice, two of these will do most of the work:

<?php

// Tags can come back in any order from the query,
// but values and types must match exactly.
$this->assertArraysHaveIdenticalValuesIgnoringOrder(
    ['php', 'testing', 'phpunit'],
    $post->tagNames(),
);

// API response: keys matter, order matters,
// but '42' == 42 is acceptable at this boundary.
$this->assertArraysAreEqual(
    ['id' => 42, 'status' => 'paid'],
    $response->json('data'),
);

Compare that to the old workaround and notice the failure output benefit too: when these assertions fail, PHPUnit knows what you actually meant, so the diff is about the dimension that broke rather than a wall of sorted noise.

withConsecutive() Finally Has a Successor

When PHPUnit 10 removed withConsecutive() in 2023, the upgrade guides all said the same thing: rewrite it with willReturnCallback() and a match expression. It worked, but it was verbose and everyone hated it. Three years later, PHPUnit 13 ships the real replacement:

<?php

$logger = $this->createMock(Logger::class);

$logger->expects($this->exactly(2))
       ->method('log')
       ->withParameterSetsInOrder(
           ['payment.started', ['invoice' => 42]],
           ['payment.completed', ['invoice' => 42]],
       );

withParameterSetsInOrder() verifies each call matches the parameter sets in the exact sequence given. Its sibling withParameterSetsInAnyOrder() accepts the same sets in any order, which is the right tool when you fan out work to a queue and the dispatch order is an implementation detail.

If your codebase still carries match-based willReturnCallback() shims from the PHPUnit 10 migration, this is your cue to delete them.

any() Is on Death Row

The any() invocation matcher is hard-deprecated in PHPUnit 13 and will be removed in a future major. The reasoning is sound: a mock exists to verify interactions. Writing $mock->expects($this->any()) declares that you do not care whether the interaction happens, which means you wanted a stub, not a mock.

The migration is mechanical. If the invocation count matters, use once(), exactly(N), atLeast(1), atMost(N), or never(). If it does not matter, switch createMock() to createStub() and drop the expects() call entirely. Stubs are also cheaper at runtime since they skip invocation bookkeeping.

Should You Upgrade Now?

Sebastian Bergmann’s own release announcement answers with a refreshing “no, you do not.” PHPUnit 12 is supported until February 2027, and the project keeps old majors compatible with new PHP versions for as long as possible.

The practical checklist: run your suite on PHPUnit 12.5 and clear every deprecation warning first. The announcement is blunt that you should not even attempt the upgrade until 12.5 runs clean. Then bump your constraint deliberately, never via a wide-open version range that upgrades you by accident.

For greenfield projects on PHP 8.4 or 8.5, start on 13 today and turn on requireSealedMockObjects from day one. You will never know the pain of the silently passing mock test, and I am a little jealous of you for it.

The roadmap ahead: PHPUnit 13.2 arrives in June 2026, with quarterly minors through 13.5 in December and PHPUnit 14 planned for February 2027. Bug fixes for the 13.x series run until February 2028.

Sources