Open the file that actually runs your test suite in CI. Not the README, the real command. Somewhere in there, often written years ago by someone who has since moved to a different company, sits a decision that deprecation notices are noise. Maybe it is an error_reporting flag, maybe a listener that swallows them, maybe just a log stream nobody reads. The build goes green. It keeps going green for years, right up to the major version where every one of those quiet notices becomes a fatal error and the green build turns out to have been a very polite liar.

terminal
php -d error_reporting=E_ALL -d display_errors=1 vendor/bin/phpunit

So here is where I land on PHP 8.6, which is due on 19 November 2026: the release itself is not the interesting part, the permission slip is. There is no engine rewrite in this one, no mass type errors, no wall like the jump to 8.0. A well tested app on 8.4 or 8.5 will bump in an afternoon. Take the days you were going to budget for this upgrade and spend them instead on making your pipeline exit non-zero when your own code raises a deprecation. Not a report. Not a dashboard. A red build.

It is not a joyless release, to be clear. Partial function application passed 33 to 0, which is a margin you basically never see on syntax, and it retroactively makes the 8.5 pipe operator worth using, because you can drop a function call with a hole in it into a pipeline instead of wrapping everything in an arrow function. Time\Duration went through 35 to 1 and gives a timeout an unambiguous type, which anyone who has ever passed milliseconds to something expecting seconds will appreciate. There is clamp(), a SortDirection enum, readonly properties with defaults, #[\Override] on class constants, and json_decode() telling you where the parse actually broke. One trap worth internalising with partials: the arguments you supply are evaluated when you build the closure, not when you call it, so a partial that bakes in something read from a request-scoped container captures that value once and keeps it. There is also new low level I/O polling plumbing in core, which matters a great deal to the people who maintain event loops and not at all to this column.

The bulk of 8.6 is roughly thirty deprecations, and most of them are the kind of work a machine does for you. is_double(), is_integer(), doubleval() and spl_object_hash() have obvious replacements. Passing an object where an array is expected is on the way out across array_walk(), mb_convert_variables(), http_build_query() and the zlib and bzip2 filter parameters, and the array_walk() vote went 41 to 3, so nobody is fighting for that behaviour. readonly stops being usable as a function name, 39 to 1. is and let become reserved vocabulary, which is the engine keeping room for pattern matching later. Returning a value from a finally block gets deprecated with the strongest vote in the whole RFC, 39 to 3. Rector's PHP_86 set plus a careful read of the diff will handle nearly all of it. That is exactly why the failing build is affordable now: the list is long but the individual fixes are cheap, so you can get to zero and then defend zero.

The honest objection, and I have made it myself: you do not control vendor code. Turn deprecations into build failures and the next minor release of a framework or a client library can paint your pipeline red for something you cannot patch, and then the pressure is to pin dependencies, which is how you end up stranded. Fair. The answer is that these are two different populations and should have two different policies. Your own namespace: zero tolerance, fail immediately. Everything under vendor: a counted baseline that you are allowed to be above zero on, with the number visible and trending down, and an issue filed upstream when it does not move. Symfony's PHPUnit bridge has done per package thresholds for years, and you can approximate the same thing with an error handler of twenty lines. What you must not do is treat both populations as noise because one of them is inconvenient.

One item on the list is not a find and replace, and it deserves calendar time rather than a Friday afternoon. Oniguruma, the regex engine behind mbstring's mb_ereg family, stopped getting upstream maintenance on 24 April 2025. PHP's response is deprecation in 8.6 and removal in 9.0. Moving mb_ereg(), mb_ereg_replace() and mb_regex_encoding() over to preg_* with the u modifier changes the semantics, not just the spelling: the two engines disagree about enough details that a pattern can keep matching and start matching slightly more or slightly less than it used to. That failure mode does not throw. It quietly lets a string through your validator. Write a test per pattern before you touch it, and if you cannot enumerate your patterns, that enumeration is the first ticket. Drupal already has an issue open for this, which tells you the usage is not exotic.

And then there is the category no deprecation counter will ever surface, because nothing is deprecated at all. Session INI defaults tighten in 8.6: session.use_strict_mode goes to 1, session.cookie_httponly goes to 1, session.cookie_samesite becomes "Lax". Good changes, all three, and each one can break a cross-site POST flow or a piece of JavaScript that has been reading the session cookie since 2017. Put those three values into your own ini file with whatever settings you have decided on, so a change shows up in a pull request rather than in a support channel. In the same spirit: trim() and friends now strip form feed by default, which matters if you slice fixed-width text; preg_grep() returns false on error instead of a partial array; a pile of functions that used to warn now throw. Your test suite may well pass through all of these. UPGRADING is still a file you read with coffee, not a file you grep.

The part I actually want to argue about is the policy, not the release. Go's toolchain refuses to compile an unused import, and Go developers stopped complaining about it approximately ten minutes after they got used to it. PHP hands you the same lever, it just leaves it switched off by default and expects you to be the adult. So: does your build fail today on a deprecation raised in your own src directory? If not, what stopped you, a vendor tree you cannot fix or a baseline nobody wanted to own? And for the teams running 400 packages in composer.json, I genuinely want to know how you keep the vendor count honest without freezing your dependencies. Answers in the comments, especially the ones that did not work.

There is a version of this upgrade where you bump the constraint, watch the suite go green, and ship. It will work. It will also mean that every notice you did not read this November comes back as a fatal error the day you try to move to 9.0, in a batch, under time pressure, probably during a release freeze. The cheap moment is the boring release. This is the boring release.