in Legacy Projects Cleanly
Anyone lifting a grown PHP project onto PHPUnit 10 or 11 runs into a flood of deprecation notices, changed assertion signatures, and removed interfaces. This article shows a structured path through the migration, without endangering the running test suite and without having to touch every test class at once.
Table of contents
- 1. Why PHPUnit upgrades in legacy projects so often escalate
- 2. Building an inventory: making deprecations visible
- 3. From PHPUnit 9 to 10: the biggest breaking points
- 4. From PHPUnit 10 to 11: hooks, attributes, and attribute migration
- 5. Modernizing outdated assertions
- 6. Magento 2: specifics of the PHPUnit upgrade
- 7. Securing the CI pipeline during the migration phase
- 8. Before and after comparison: critical changes
- 9. Summary
- 10. FAQ
1. Why PHPUnit upgrades in legacy projects so often escalate
PHPUnit upgrades are treated as a routine task by many teams, until the first project is actually migrated. Then reality shows itself: hundreds of test classes use the methods assertFileNotExists, assertRegExp, or withConsecutive, which have been marked deprecated for years. Other classes inherit from PHPUnit\Framework\TestCase subclasses that Magento or other frameworks bring along themselves, and which in turn rely on outdated internal APIs. On top of that come bootstrap scripts that are no longer compatible with the new configuration structure of PHPUnit 10.
The real problem is not the scope of the changes but their invisibility. PHPUnit 9 only shows deprecation notices when the corresponding code is actually executed, and only when --display-deprecations is set or the project's own code does not suppress the warnings. Without a systematic stocktake you stumble into the migration blind and only discover, when you try to install the new version, that 300 places need adjusting.
The structured path through such a migration consists of four phases: taking inventory, adjusting step by step on a separate branch, securing the CI pipeline during the migration phase, and a final cleanup. Anyone who tries to run PHPUnit 9 and 11 at the same time will fail, Composer does not allow it, and polyfill packages only solve part of the problem.
2. Building an inventory: making deprecations visible
Before touching a single line of code, get a complete overview of every affected spot. The simplest way: run PHPUnit 9 with deprecation output enabled against the full test suite and pipe the output into a file. The result shows which methods are affected and how often they are used.
In addition, a simple grep across the entire test directory helps find outdated method names that may sit in abstract base classes and are inherited by dozens of test classes. Those spots have the biggest leverage, because one change cleans up many tests at once. Tools like rector/rector with the PHPUnit set can automate most of the mechanical renaming.
<?php
// Inventory step 1: run with full deprecation output
// vendor/bin/phpunit --display-deprecations 2>&1 | tee /tmp/phpunit-deprecations.txt
// Inventory step 2: grep for known deprecated methods
// grep -rn "assertRegExp\|assertNotRegExp\|assertFileNotExists\|withConsecutive\|getMockBuilder" tests/
// Inventory step 3: rector dry-run to see what would change automatically
// vendor/bin/rector process tests/ --dry-run --config rector.php
// rector.php - PHPUnit migration config
use Rector\Config\RectorConfig;
use Rector\PHPUnit\Set\PHPUnitSetList;
return RectorConfig::configure()
->withPaths([__DIR__ . '/tests'])
->withSets([
PHPUnitSetList::PHPUNIT_90,
PHPUnitSetList::PHPUNIT_100,
PHPUnitSetList::ANNOTATIONS_TO_ATTRIBUTES,
])
->withPhpSets(php84: true);
Rector reliably handles the purely mechanical renames: assertRegExp to assertMatchesRegularExpression, assertFileNotExists to assertFileDoesNotExist, PHPDoc annotations to PHP 8 attributes. What Rector cannot do: rewrite the logic behind withConsecutive chains, because that requires understanding the content of the test. Those spots have to be reworked manually, but the inventory shows exactly where they are.
3. From PHPUnit 9 to 10: the biggest breaking points
PHPUnit 10 is the first truly breaking release in a long time. The configuration file phpunit.xml has a new structure, the old attributes cacheResultFile, executionOrder, and the syntax for test suites have changed. PHPUnit 10 refuses to start if the configuration does not match the new XSD. PHPUnit handles the schema upgrade itself: vendor/bin/phpunit --migrate-configuration adjusts the file automatically.
The most important content change concerns withConsecutive: the method was removed entirely. It allowed defining different arguments and return values for a mock on consecutive calls. The replacement is an explicit implementation with a counter or a queue. That is more code, but noticeably clearer and less error prone during refactorings of the production class.
<?php
declare(strict_types=1);
namespace Mironsoft\Tests\Unit;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\DataProvider;
// BEFORE (PHPUnit 9): withConsecutive - removed in PHPUnit 10
// $mock->expects($this->exactly(3))
// ->method('process')
// ->withConsecutive(['a'], ['b'], ['c'])
// ->willReturnOnConsecutiveCalls(1, 2, 3);
// AFTER (PHPUnit 10+): explicit queue approach
final class OrderProcessorTest extends TestCase
{
#[Test]
public function processesItemsInSequence(): void
{
$calls = [['a'], ['b'], ['c']];
$returns = [1, 2, 3];
$callIndex = 0;
$mock = $this->createMock(ItemProcessor::class);
$mock->expects($this->exactly(3))
->method('process')
->willReturnCallback(function (string $item) use (&$callIndex, $calls, $returns): int {
$this->assertSame($calls[$callIndex][0], $item);
return $returns[$callIndex++];
});
$processor = new OrderProcessor($mock);
$result = $processor->run(['a', 'b', 'c']);
$this->assertSame([1, 2, 3], $result);
}
}
4. From PHPUnit 10 to 11: hooks, attributes, and further cleanups
PHPUnit 11 consolidates the shift towards PHP 8 attributes and finally removes support for PHPDoc annotations as a control format. Tests that still use /** @test */ or /** @dataProvider */ as PHPDoc comments are ignored, they no longer run, without producing an error message. This is particularly treacherous: a test that is silently ignored appears as a green checkmark in CI, even though it was never executed.
Rector handles the migration of the annotations fully automatically with the set PHPUnitSetList::ANNOTATIONS_TO_ATTRIBUTES. All @test annotations become #[Test] attributes, @dataProvider becomes #[DataProvider('methodName')], @depends becomes #[Depends('testMethodName')]. After the Rector run the test suite should be fully green, with the same number of executed tests as before.
<?php
declare(strict_types=1);
namespace Mironsoft\Tests\Unit\Catalog;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Depends;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Mironsoft\Catalog\Service\PriceCalculator;
// BEFORE PHPUnit 11 (annotations - silently ignored in PHPUnit 11):
// /**
// * @test
// * @dataProvider priceProvider
// */
// public function calculatesCorrectPrice(float $net, float $vat, float $expected): void
// AFTER PHPUnit 11 (PHP attributes - correct):
#[CoversClass(PriceCalculator::class)]
final class PriceCalculatorTest extends TestCase
{
#[Test]
#[DataProvider('priceProvider')]
public function calculatesCorrectPrice(float $net, float $vat, float $expected): void
{
$calculator = new PriceCalculator();
$this->assertEqualsWithDelta($expected, $calculator->gross($net, $vat), 0.001);
}
/**
* @return array<string, array{float, float, float}>
*/
public static function priceProvider(): array
{
return [
'standard rate DE' => [100.0, 19.0, 119.0],
'reduced rate DE' => [100.0, 7.0, 107.0],
'zero rate' => [100.0, 0.0, 100.0],
];
}
}
5. Modernizing outdated assertions
Over the years, PHPUnit has renamed many assertion methods but kept the deprecated variants around for a long time. In PHPUnit 10 and 11 these aliases are finally removed. The list of the most common renames is manageable, but the number of affected spots in a grown project can run into the hundreds. Rector handles the mechanical renames, but it is worth understanding the changes manually, because some renames also slightly change the semantics.
Special caution applies to assertEquals versus assertSame. The former compares with == (type-loose), the latter with === (type-strict). In many legacy projects assertEquals is used for integer comparisons where assertSame is actually meant. That is not a violation of the new PHPUnit API, but a latent weakness in the test: assertEquals(0, false) is true, assertSame(0, false) is false. The migration is a good moment to clean up these spots.
6. Magento 2: specifics of the PHPUnit upgrade
Magento 2 couples PHPUnit through its own test framework classes such as Magento\TestFramework\TestCase\AbstractController and Magento\Framework\TestFramework\Unit\BaseTestCase. These classes inherit from PHPUnit and contain their own lifecycle hooks and assertions. During a PHPUnit upgrade, the Magento framework itself must therefore also be compatible, which means you cannot simply update PHPUnit to 11 if Magento 2.4.7 still requires PHPUnit 9.
The practical path in Magento projects: first update to the Magento version that officially supports the new PHPUnit. Magento 2.4.8 supports PHPUnit 10. After that, your own modules and test classes can be migrated step by step to the new API. Custom bootstrap files in dev/tests/unit/framework/bootstrap.php need to be adjusted to the new configuration structure. Particularly vulnerable: the objectManager pattern in unit tests, which occurs frequently in Magento projects but is considered an anti-pattern in plain PHPUnit tests.
<?php
declare(strict_types=1);
namespace Mironsoft\Catalog\Test\Unit\Model;
use Magento\Framework\TestFramework\Unit\Helper\ObjectManager;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Mironsoft\Catalog\Model\ProductEnricher;
use Mironsoft\Catalog\Api\Data\ProductInterface;
/**
* PHPUnit 10-compatible unit test for Magento 2 module.
* Avoids deprecated ObjectManager helper in favour of direct constructor injection.
*/
#[CoversClass(ProductEnricher::class)]
final class ProductEnricherTest extends TestCase
{
private ProductEnricher $enricher;
protected function setUp(): void
{
// Prefer direct DI over ObjectManager in unit tests
$priceService = $this->createMock(\Mironsoft\Catalog\Api\PriceServiceInterface::class);
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
$this->enricher = new ProductEnricher($priceService, $logger);
}
#[Test]
public function enrichAddsGrossPriceToProduct(): void
{
$product = $this->createMock(ProductInterface::class);
$product->method('getNetPrice')->willReturn(100.0);
$product->expects($this->once())->method('setGrossPrice')->with(119.0);
$this->enricher->enrich($product, 19.0);
}
}
7. Securing the CI pipeline during the migration phase
During the migration phase, old and new tests run in parallel. The CI pipeline must be configured so that deprecation notices are visible as warnings but do not immediately break the pipeline. This is achieved with --display-deprecations and a separate quality gate that can measure the number of deprecations over time and react to regressions. A simple approach: pipe the deprecation output into a file and store its line count as a metric.
Composer constraints during the migration: "phpunit/phpunit": "^9.6 || ^10.5" allows parallel testing on both versions. Once all tests run on 10, the constraint is narrowed to "^10.5". For Magento projects, a separate CI job is recommended that runs exclusively your own module tests under the new PHPUnit version, the Magento core tests keep running under the version prescribed by the framework.
8. Before and after comparison: critical changes
The following table shows the most important API changes when upgrading from PHPUnit 9 to 11 and the corresponding replacements. All entries in the left column lead to a deprecation notice in PHPUnit 10 and to an error in PHPUnit 11.
| PHPUnit 9 (deprecated) | PHPUnit 10/11 (correct) | Automatable? | Note |
|---|---|---|---|
assertRegExp() |
assertMatchesRegularExpression() |
Rector | Pure rename |
withConsecutive() |
willReturnCallback() with queue |
Manual | Logic change required |
/** @test */ |
#[Test] |
Rector | Mandatory in PHPUnit 11 |
assertFileNotExists() |
assertFileDoesNotExist() |
Rector | Pure rename |
phpunit.xml old structure |
--migrate-configuration |
PHPUnit itself | Run once |
The table shows that the majority of the changes are automatable. The effort concentrates on the spots with withConsecutive, which must be rewritten manually, and on custom bootstrap files, which must account for the new phpunit.xml structure. In projects with thousands of tests it makes sense to first clean up the automatable spots with Rector and then prioritize the remaining manual adjustments with grep lists.
9. Summary
PHPUnit upgrades in legacy projects are plannable and can be carried out with low risk if you follow four steps: build an inventory with --display-deprecations and grep, carry out automatable changes with Rector, prioritize manual spots (especially withConsecutive), and secure the CI pipeline for the transition phase with parallel Composer constraints. Magento projects additionally require that the Magento version supports the new PHPUnit before your own tests are migrated.
The migration is also an opportunity: PHPUnit 11 with PHP 8 attributes is noticeably more readable than annotation-based tests. #[Test], #[DataProvider], and #[CoversClass] are type safe, IDE supported, and no longer require parsing docblocks. Whoever does not postpone the migration also benefits from the improved error reporting and the new assertion methods in PHPUnit 11.
PHPUnit upgrade, the essentials at a glance
Inventory first
--display-deprecations and grep before the first code change. Rector shows what is automatable.
withConsecutive manually
No Rector replacement possible. willReturnCallback with an internal counter is the cleanest replacement.
Annotations to attributes
In PHPUnit 11, @test annotations are silently ignored. Rector migrates fully automatically to #[Test].
Magento timing
First update Magento to a version that supports PHPUnit 10 (Magento 2.4.8). Then migrate your own tests.
10. FAQ: PHPUnit Deprecations and Upgrades in Legacy Projects
1Use PHPUnit 9 and 11 at the same time?
^9.6 || ^10.5 as a constraint with two CI jobs.2Fastest way to find all deprecations?
--display-deprecations combined with grep across the test directory and Rector --dry-run.3What replaces withConsecutive in PHPUnit 10?
willReturnCallback with an internal counter or queue. No Rector replacement, manual adjustment required.4@test annotations reported as an error in PHPUnit 11?
#[Test].5Migrate phpunit.xml to the new structure?
vendor/bin/phpunit --migrate-configuration updates the file once to the new XSD structure.6Does Rector fix all deprecations?
withConsecutive and bootstrap files.7Which Magento version supports PHPUnit 10?
8assertEquals vs. assertSame for integers?
assertSame is correct, type-strict with ===. assertEquals is type-loose and would rate assertEquals(0, false) as true.9Avoid ObjectManager in Magento unit tests?
createMock(). ObjectManager obscures the dependency graph and is an anti-pattern in unit tests.10How long does a PHPUnit 9 to 10 upgrade take?
withConsecutive migrations and bootstrap: 1-3 further days.