The silhouette of a person with a backpack climbing a rocky crest, lit from behind. One hand rests on the rock ahead, one leg has already followed, and the next hold is within reach. Conifers stand in the haze all around, the sun breaks over a ridge at the top right, and a warm veil lies over the whole scene. An image for a climb that consists of individual holds, every one of which is manageable on its own.
The next hold is within reach. It stays that way only if you do not wait until the wall turns vertical.

Moving to a new major version of PHPUnit is a change to one line in your composer.json. If it is not that for you, the reason is not the upgrade. The reason is the year before it.

I see both kinds of project in trainings and in consulting, when I work with a team on its test suite, and in conversations with developers at conferences and meetups. Some upgrade in February and are done by the afternoon. Others are three majors behind and treat the upgrade as a project with a budget, a branch of its own and a name of its own. Both are looked after by competent people, and measured over a few years the effort comes out roughly the same. What differs is the timing. One pays as it goes, the other pays all at once and with interest.

I have written this article before, in February 2019, shortly after PHPUnit 8 came out and a number of developers made it clear on social media what they thought of the changes. It is called Help! My tests stopped working, and it still holds. That is why this text says nothing about what any particular version added or removed. The mechanism underneath has not changed in seven years, and it will outlast the version you are on.

The release cycle is a promise

PHPUnit gets a new major version every year, on the first Friday of February. Minor versions arrive in between, on the first Friday of April, June, August, October and December. Patch releases happen when they are needed.

That calendar is not trivia. It tells you what kind of change can reach you at a given moment. A patch release fixes bugs and changes no behaviour. A minor version adds functionality in a backward-compatible way and will not break your test suite. Only a major version removes anything.

For eleven months of the year, the question “will updating PHPUnit break my build?” therefore has a boring answer. The twelfth month is a date you can put in your calendar three years in advance.

Nothing disappears by surprise

A feature on its way out of PHPUnit passes through three stages, and there is always a major version between two of them.

A soft deprecation marks the affected API as @deprecated. Nothing happens at runtime. Your IDE strikes the call through, your static analysis reports it, your test suite does not notice it.

The hard deprecation in the next major version turns it into a message during test execution. The feature works exactly as it did before. PHPUnit is telling you something, it is not doing something to you.

Removal comes only in the major version after that.

The any() matcher is going through this sequence right now. PHPUnit 12.5 soft-deprecated it, PHPUnit 13 hard-deprecated it in February 2026, and PHPUnit 14 will remove it in February 2027. Why it has to go and what the migration looks like is written up in From anti-pattern to clarity. A full year sits between the first message in the test run and the disappearance, and longer still between the soft deprecation and the disappearance.

The deprecations in your test output are the changelog of your next upgrade, delivered a year early, one line at a time. In most projects nobody reads them.

The number is already on your screen

This is where things go wrong in a lot of projects, and the cause is easy to miss. PHPUnit distinguishes two kinds of deprecation, and only one of them matters for a PHPUnit upgrade.

--display-deprecations shows you the deprecations your code triggers: E_DEPRECATED from PHP itself, E_USER_DEPRECATED from your dependencies. That is the list for your next PHP upgrade. --display-phpunit-deprecations shows you the deprecations of PHPUnit's own API. That is the list for your next PHPUnit upgrade. The option has been there since PHPUnit 10.5.32, released in September 2024. Whoever sets the first option and is pleased with an empty list has asked the wrong question.

The number itself needs no flag. As soon as a PHPUnit deprecation has been triggered, PHPUnit writes “OK, but there were issues!” at the end of the test run, with a line “PHPUnit Deprecations: n” below it. What the option adds is the list behind that number: which test, which line, which feature.

If that line has never caught your eye, there are two possible reasons. Either your test suite is clean. Or your CI only looks at the exit code and nobody reads the last five lines of the output.

The order is the whole trick

Six steps. More important than the list is where in it the change to composer.json sits.

  1. Get onto the latest patch release of your current major version
  2. Run your test suite with --display-phpunit-deprecations
  3. Fix every deprecation it reports
  4. Upgrade PHP if the next major version of PHPUnit requires a version you are not on yet
  5. Change the version constraint in composer.json
  6. Read the ChangeLog and the release announcement

Four of the six steps happen before the change to the constraint, on a version where the old API and the new API both exist. That is what matters. You clear one deprecation after another, and after every single one your test suite is green. You can stop at any point, and you can ship at any point. Nothing is ever half migrated along the way.

The usual approach reverses the order. Change the constraint first, then sit in a branch where nothing runs until everything is fixed. The error list waiting for you there is exactly as long as the list of deprecations nobody read for a year. It is the same work in a shape you cannot stop in the middle of.

Step 4 deserves a note, because it is the only one that does not happen in your CI. A new major version of PHPUnit sometimes requires a newer PHP version, and upgrading PHP touches production. It needs a deployment window and a rollback plan. Start it early, therefore, in parallel with steps 1 to 3. PHPUnit will not ask you for a PHP version that PHP itself has not been asking you for already: PHPUnit 13 requires PHP 8.4 and was released in February 2026, five weeks after active support for PHP 8.3 had run out on 31 December 2025. If it is PHPUnit of all things that puts the PHP upgrade on your roadmap, it has forced a conversation you owed yourself anyway.

Step 6 is not filler. Read the release announcement after the upgrade, when you have five quiet minutes. That is where you find out what you have just been given. A year from now, the features you did not notice are the ones whose absence will bother you.

The ratchet

Everything so far describes a single upgrade. The teams for which February is a formality do one additional thing: they do not let deprecations accumulate in the first place.

The mechanism for that is a ratchet. Run --display-phpunit-deprecations in CI so the messages are visible instead of buried. Get the number to zero. If that is not realistic this quarter, get it to some number and then never let it rise again. Once you are at zero, set failOnPhpunitDeprecation="true" in your phpunit.xml, and from then on CI holds the line for you.

There is no shortcut through the baseline. --generate-baseline records the deprecations, notices and warnings your code triggers, not the deprecations of PHPUnit's own API. That is deliberate. A baseline is meant for problems you have to live with permanently, because they come out of code that is not yours. A PHPUnit deprecation is a different thing: it sits in your test code, and its expiry date is in the calendar.

This ratchet has a side effect that is rarely named. Whoever is current in February gets bug fixes and improvements while they are fresh, and reports problems while I still have the context in my head. Feedback from a project working on the current version changes what comes next. Feedback on a version released three years ago changes nothing about it any more.

Why I make you do this work

So far this sounds like a procedure that takes nothing but discipline. I do not want to leave out the other side. Every feature I remove creates work in other people's projects. I do not get the bill, you do.

I do it anyway, because the alternative is more expensive and merely stays invisible. A testing framework that keeps every API it ever had, after twenty-five years, offers three ways to do each job, two of which are wrong. Whoever joins the team then picks the wrong one as often as the right one. Nobody books those costs as effort, because they do not show up as a migration on one afternoon. They show up over years as worse tests.

The trade therefore does not work out in every project, and I do not claim that it does. There are codebases whose test suite has run unchanged for years and which nobody touches any more. Standing still is the right decision there. Old PHPUnit versions do not stop working, they only stop being repaired. Bugfix support for a major version ends two years after its release, on the day the one after next comes out. The difference between “green” and “supported” becomes apparent on exactly the day you hit a bug that was fixed two years ago, in a version you cannot reach without a two-week migration.

Conclusion

The upgrade is not the hard part. The hard part is spending a year turning it into one.

PHPUnit 14 is released on the first Friday of February 2027. What it removes is what PHPUnit 13 has been reporting to you on every test run, and with --display-phpunit-deprecations it reports it with a test name and a line number too. That leaves three months.