My own blog’s Site Health screen said PHP 8.1.2. A patch level from early 2022, running in September 2026, on a server open to the internet. I had been putting the PHP 8.5 upgrade off for years with “I’ll get to it”; what I was putting off no longer had the name old, it had the name unpatched.
I moved to 8.5.9 in a single day. Nothing broke — but there were five traps along the way and I stepped in three of them. Let’s take them in order: what it really costs to keep a retired version on shift, what the control panel does not tell you, and the single missing character that took my whole site down for a minute and a half.
When Did PHP 8.1 Actually Retire?
PHP’s release calendar is not vague: every branch gets two years of active support (bugs and security), then two years of security fixes only, then nothing. PHP 8.1’s security support ended on 31 December 2025. So for eight months now, when a new vulnerability turns up in 8.1, no patch comes down from upstream.
Here is the sneaky part: the version number on its own can lie to you. If you run your distribution’s package, Debian and Ubuntu backport security fixes onto the old version number; it says 8.1.2 while carrying patches from 2025. But if you run the PHP your control panel compiled — CWP, cPanel’s EasyApache, any of them — that binary is the code as it stood on the day the panel built it. No backports.
The hard truth: I was on CWP’s PHP-FPM selector. So that 8.1.2 really was 8.1.2.
To find out which situation you are in, start with where the binary came from:
# Distribution package, or panel build?
php -v
which php
dpkg -l | grep -i php8 # Debian/Ubuntu
rpm -qa | grep -i php # RHEL/AlmaLinux/CentOS
If the package manager does not know about your PHP, that PHP belongs to the panel. And a retired version the panel compiled is genuinely retired.
What “Requires PHP 7.4” Does Not Mean
The real question before upgrading was compatibility. WordPress itself was clear: in May 2026 the Core team retired the “beta support” label entirely and documented WordPress 6.9 and 7.0 as fully supporting PHP 8.5.
On the plugin side there is a misreading almost everyone makes. That “Requires PHP: 7.4 or higher” line on a plugin page is a floor, not a ceiling. There is no upper-bound field in WordPress’s readme format at all. That line is not telling you “this works on 8.5”; it is telling you “this will not run below 7.4”. Those are not the same sentence.
The signal that actually matters is elsewhere: the last update date and whether the developer is still around. I tabled my own seven plugins with that in mind (values as of 21 September 2026):
| Plugin | Last updated | Risk |
|---|---|---|
| Yoast SEO | 6 days ago | Low |
| MonsterInsights | 2 days ago | Low |
| Polylang | 2 weeks ago | Low |
| Software License Manager | 1 month ago | Low |
| CodePen Embed Block | 1 month ago | Low |
| reCaptcha (BestWebSoft) | 5 months ago | Medium |
| Code Syntax Block | 2 years ago | Abandoned |
Buried treasure: building that table is how I found out Code Syntax Block was pulled from the WordPress.org directory in April 2025. My dashboard had been showing it as “up to date” for two years. Which makes sense, in a grim way: a closed plugin never announces an update, so it looks current forever. Do not let the updates screen put your mind at ease.
What Does PHP 8.5 Actually Break?
“8.1 to 8.5” sounds like a four-step jump and that is exactly what scares people off. The truth is that 8.5’s list of breaking changes is embarrassingly short. The only thing removed outright is the -z / --zend-extension option in the CLI. It has nothing to do with your web application.
Everything else is a deprecation, meaning it raises E_DEPRECATED and does not go fatal:
- Non-canonical type casts:
(boolean),(integer),(double),(binary)→(bool),(int),(float),(string) mysqli_execute()→mysqli_stmt_execute()curl_close()andcurl_share_close()(now no-ops)socket_set_timeout()→stream_set_timeout()- All the
MHASH_*constants - Using
nullas an array offset — hold on to that one, we come back to it shortly
The bloody migrations were 8.0 and 8.1. If you have already crossed that bridge, 8.5 is calmer than you think. If you want the feature side, I wrote up what the previous wave brought in my post on PHP 8.4 (in Turkish); 8.5 builds on top of it.
The Upgrade in CWP and the Extension Trap
Compiling the new version in the panel and assigning it to a domain is a ten-minute job. The trap is this: PHP extensions do not carry over to the new version. Every PHP minor version compiles its own extensions. If you do not tick the list on the build screen, your new PHP arrives spotless and alone.
The minimum list for WordPress:
mysqli pdo_mysql mbstring curl gd exif zip
intl dom xml simplexml json opcache fileinfo
Watch imagick in particular. Because it comes via PECL, most build templates leave it unticked by default. Without it WordPress quietly falls back to GD; images still work, but WebP/AVIF handling and output quality get worse. Mine came through, and I checked:
php -m | sort
php -r 'var_dump(extension_loaded("imagick"), extension_loaded("intl"));'
After the switch, read Site Health’s “missing recommended module” notice again. If the list is longer than it was before the upgrade, something dropped.
Also switch per domain, not server-wide. If the same account runs something tied to a C extension — Phalcon, for instance, which needs a separately compiled binary for every PHP minor version — changing the version server-wide takes that application down in one move with “extension not loaded”.
I Turned the Log On and Nothing Was Written
The whole point of the migration was to see the deprecations. I put this in wp-config.php and waited:
define( 'WP_DEBUG', false );
define( 'WP_DEBUG_LOG', true );
Hours passed and not one line appeared. Because those two lines together do nothing at all.
Technical detail: in WordPress core, wp_debug_mode() keeps the entire logging configuration inside the if ( WP_DEBUG ) branch. When WP_DEBUG is false, WP_DEBUG_LOG is never read — in the documentation’s own words, WP_DEBUG_DISPLAY and WP_DEBUG_LOG perform no function unless WP_DEBUG is true. On top of that, in the false branch WordPress narrows error_reporting to this mask:
E_CORE_ERROR | E_CORE_WARNING | E_COMPILE_ERROR | E_ERROR
| E_WARNING | E_PARSE | E_USER_ERROR | E_USER_WARNING
| E_RECOVERABLE_ERROR
E_DEPRECATED is not on that list. So the one thing I wanted to see was precisely the thing I could not.
For a migration window, this is the correct shape — and move the log path outside the web root, because the default wp-content/debug.log sits somewhere a browser can read:
define( 'WP_DEBUG', true ); // error_reporting( E_ALL )
define( 'WP_DEBUG_DISPLAY', false ); // visitors see nothing
define( 'WP_DEBUG_LOG', '/home/user/logs/wp-debug.log' );
define( 'SCRIPT_DEBUG', false );
@ini_set( 'display_errors', 0 ); // belt and braces
Turning WP_DEBUG on in production is safe as long as WP_DEBUG_DISPLAY stays false; all it does is raise error_reporting. But keep it time-boxed: switch it off when the job is done.
568 Deprecation Lines, One Source
Once the log started flowing, 568 lines piled up in half an hour. All of them the same sentence:
PHP Deprecated: Using null as an array offset is deprecated,
use an empty string instead in .../plugins/wordpress-seo/src/
memoizers/meta-tags-context-memoizer.php on line 135
My guess was some stale plugin. I was wrong: Yoast SEO. The most current, best-maintained, modern PSR-4 plugin on the blog. Two memoizer classes build a cache key from a value that can be null; PHP used to coerce that to an empty string silently, and 8.5 warns about it. No functional impact, the cache still works.
I searched the line numbers: there are two separate threads on the WordPress.org support forum with exactly the same files and line numbers. Yoast’s official answer is that the deprecations do not break functionality and can safely be ignored, and that a fix will come in due course. No version, no date.
The upshot: you are on the latest version and there is nothing you can do. This is what the ecosystem’s slow adoption of new PHP releases looks like up close: not breakage, just noise nobody is in a hurry to clear.
The point of the audit was never to find Yoast anyway, it was to see what else was in there. Filter out the dominant source and read the rest:
LOG=/home/user/logs/wp-debug.log
# Drop the noise, count what is left per plugin
grep "Deprecated:" "$LOG" | grep -v "plugins/wordpress-seo/" \
| grep -oP '(plugins|themes)/\K[a-z0-9-]+' | sort | uniq -c | sort -rn
# The ones that actually matter: everything that is not a deprecation
grep -E "PHP (Fatal error|Parse error|Warning|Recoverable)" "$LOG" \
| sed 's/on line [0-9]*/on line N/' | sort | uniq -c | sort -rn
The second command came back empty for me. Seven plugins, two languages, one theme — not a single fatal, not a single warning. That was the whole bill for a four-step jump.
The Site That Went Down Over One Missing Character
With the upgrade done, hardening was next: security headers, closing readme.html, blocking xmlrpc.php. I pasted the block I had prepared into .htaccess. Then the entire site started returning 500.
Home page, login screen, feed — all of it. Even a static CSS file.
My first instinct was to blame PHP, then the upgrade. It was neither. A static .css file returning 500 was telling me PHP was not even in the picture — this was Apache failing to read its own configuration. The cause:
==========================================================
# HARDENING
# ==========================================================
The # on the first line got lost in the copy. Apache reads that line as a directive rather than a comment, finds no command called ==========, rejects the whole file, and returns 500 for every request in that directory.
The lesson: if static files are falling over too, leave PHP, the database and the plugins alone. Look at the web server configuration. And back .htaccess up before you edit it; a syntax error in that file does not take down one request, it takes down the whole directory. On the Apache side, what I wrote about vhost configuration (in Turkish) still holds — though the CentOS 8 in that post has since retired itself, which is a subject for another day.
The Post-Upgrade Checklist
- Backup. Files plus
mysqldump. A PHP version can be rolled back; a corrupted database cannot. - Extension list. Compare
php -moutput from before and after the upgrade. - Clear OPcache and restart FPM.
php.inichanges always need a restart. - Test real routes. Home page, a post, a page with a form, the admin panel, the feed, the sitemap.
- 48 hours of logging. Then switch
WP_DEBUGoff but leavelog_errorsanderror_loginphp.ini— fatals and warnings keep getting logged, deprecations go quiet. display_errors = Offin production. Write it explicitly, do not leave it to inheritance.
For the permanent setup, the ini side should look like this:
display_errors = Off
log_errors = On
error_log = /home/user/logs/php-error.log
Those three work independently of WP_DEBUG, because in the false branch WordPress does not touch the log_errors and error_log ini values. As a bonus, errors that happen before WordPress loads land in the same file.
What Keeping a Retiree on Shift Costs You
PHP 8.5 was released on 20 November 2025; active support runs to 31 December 2027 and security support to 31 December 2029. Move today and you do not open this subject again for three years. Put it off and you will do the same work later, with a codebase that has aged a little more every month in between.
Here is what a day’s work left me with: four versions jumped, not one fatal, and my only finding was cosmetic noise from a plugin. The thing that actually took me down was not PHP — it was a missing character at the start of a comment line. I once wrote about why PHP refuses to die (in Turkish); not retiring a language that refuses to die is on us.
Go check the version on your server. Find out whether it is the panel’s build or the distribution’s package. And do the upgrade with the log open — you cannot fix what you cannot see.
My next retiree is already picked out: the same server runs MariaDB 10.4.34. Support for 10.4 ended on 18 June 2024, and 10.4.34 is the last release in that series. Which makes the database the oldest part of the stack now. That story deserves its own post.
Stay technical, and keep your versions fresh.