- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
Open W3Techs and look up WordPress. About 40% of the public web. Then look up the PHP version distribution for those sites. The plurality is still PHP 7.4. End-of-life since November 2022. No security patches. No bugfixes. Just a lot of wp-admin dashboards quietly running on a runtime that hasn't been touched in three years.
A friend who runs a small agency told me last month that every onboarding call starts the same way. The client's host is running PHP 7.4 because "WordPress recommends it." That hasn't been true since 2021. The WordPress docs page that scared everyone off PHP 8 has been rewritten twice. Nobody re-read it.
You can run WordPress on PHP 8.4. You can put FrankenPHP in front of it. You can drop in Redis object cache and double your page rate. You don't need to rewrite a single plugin.
Why WordPress is stuck on PHP 7.4 (and why it doesn't have to be)
The history is real. PHP 8.0 changed how named arguments and constructor promotion interact with the older WordPress hook system. Some popular plugins broke. WPML had issues. Older versions of WooCommerce threw deprecation notices on every admin page. The WordPress core team marked PHP 8.0 as "beta supported" for a long time, and that label stuck in the collective memory even after they dropped it.
As of WordPress 6.7, the official requirements page lists PHP 8.0–8.3 as supported. PHP 8.4 (released November 2024) works for core. The friction is plugins, not WordPress itself.
The mistake most teams make is treating "officially supported" as "the only safe version." If your plugin stack is modern (recent WooCommerce, recent ACF Pro, Yoast, WP Rocket, modern security plugins), PHP 8.4 runs fine. The plugins that break are the ones written in 2016 that nobody's touched since.
So the upgrade isn't "PHP 8.4 is dangerous." It's "audit your plugins."
Pattern 1: Bedrock + Composer to modernise plugin management
The default WordPress installation is a folder of PHP files committed to a Git repo with wp-content/plugins/ full of unpacked ZIPs from random websites. You update plugins by clicking a button in wp-admin, which writes to the filesystem of the production server, which means your Git history and your production state diverge instantly.
Bedrock by Roots fixes this. It restructures WordPress so plugins, themes, and core itself are Composer dependencies, with wp-content outside the web root and configuration driven by .env files instead of wp-config.php.
A working composer.json for a Bedrock WordPress on PHP 8.4 in 2026:
{
"name": "your-agency/example-site",
"type": "project",
"license": "MIT",
"description": "WordPress on PHP 8.4 with Composer.",
"config": {
"preferred-install": "dist",
"allow-plugins": {
"composer/installers": true,
"roots/wordpress-core-installer": true
}
},
"repositories": [
{ "type": "composer", "url": "https://wpackagist.org" },
{ "type": "composer", "url": "https://composer.deliciousbrains.com" }
],
"require": {
"php": ">=8.4",
"composer/installers": "^2.3",
"vlucas/phpdotenv": "^5.6",
"oscarotero/env": "^2.1",
"roots/bedrock-autoloader": "^1.0",
"roots/wordpress": "6.7.2",
"roots/wp-config": "1.0.0",
"roots/wp-password-bcrypt": "1.1.0",
"wpackagist-plugin/wordpress-seo": "^23.0",
"wpackagist-plugin/wp-super-cache": "^1.13",
"wpackagist-plugin/redis-cache": "^2.5",
"wpackagist-plugin/query-monitor": "^3.16",
"wpackagist-plugin/woocommerce": "^9.4",
"wpackagist-theme/twentytwentyfive": "*"
},
"extra": {
"installer-paths": {
"web/app/mu-plugins/{$name}/": ["type:wordpress-muplugin"],
"web/app/plugins/{$name}/": ["type:wordpress-plugin"],
"web/app/themes/{$name}/": ["type:wordpress-theme"]
},
"wordpress-install-dir": "web/wp"
}
}
The pieces matter. wpackagist.org repackages every plugin and theme from the WordPress.org repository as Composer packages. That's what makes wpackagist-plugin/wordpress-seo resolvable. The installer-paths block keeps plugins out of web/wp so core is upgradable without touching your code. wp-password-bcrypt switches WordPress's password hashing from phpass (a 2008 library that's still in core) to native password_hash() with bcrypt. Closer to a sane default in 2026.
Paid plugins like ACF Pro are sold via Composer registries (Delicious Brains hosts them). You add the registry URL to repositories and a license key to your .env. No more downloading ZIPs and uploading them via FTP.
The day-to-day workflow:
# add a plugin
composer require wpackagist-plugin/wp-rocket
# update everything
composer update
# install on a fresh server
composer install --no-dev --optimize-autoloader
# generate WordPress salts and write to .env
wp dotenv salts regenerate --file=.env
Your Git repo now contains your code and a composer.lock. Production state is reproducible from the lockfile. When you find out a plugin broke, you git revert and redeploy. That alone is worth the switch.
Pattern 2: FrankenPHP or RoadRunner in front of WordPress
The classic WordPress hosting stack is Nginx → PHP-FPM. Each request spins up a fresh PHP worker, bootstraps WordPress (which means reading and parsing 300+ files, hitting MySQL for options, instantiating every active plugin), serves the response, then throws everything away.
PHP 8.4 has opcache, JIT, and preloading. WordPress still bootstraps from scratch on every request.
FrankenPHP is a modern PHP server built on top of Caddy. It can run PHP in classic mode (one process per request, same as PHP-FPM) or worker mode (long-lived workers that boot once and handle thousands of requests). For WordPress, worker mode is where the gains live.
There's a complication. WordPress was designed assuming every request is a fresh PHP process. Plugin code does things like global $wpdb; at file scope, registers hooks via static state, and assumes $_SERVER superglobals reset between requests. Naive worker mode breaks these assumptions.
The fix is the official WordPress worker example that ships with FrankenPHP. It resets WordPress state between requests via frankenphp_handle_request(). A working Caddyfile for a Bedrock site:
{
frankenphp {
# one worker per CPU core, handling 1k requests before recycle
worker /srv/app/web/index.php 4 1000
}
}
example.com {
root * /srv/app/web
encode zstd br gzip
# static assets: let Caddy serve them, never hit PHP
@static {
path *.css *.js *.woff *.woff2 *.svg *.png *.jpg *.webp *.avif
path /app/uploads/* /app/themes/*/assets/*
}
header @static Cache-Control "public, max-age=31536000, immutable"
file_server @static
# wp-admin and wp-login: classic mode, no worker
# admin sessions mutate global state in ways worker mode dislikes
@admin path /wp/wp-admin/* /wp/wp-login.php /wp/wp-cron.php
php @admin
# everything else: worker mode
php_server {
index index.php
}
}
The split matters. Worker mode is brilliant for the front of the site (product pages, posts, archives), where the same handful of templates render for everyone. Admin is full of one-off forms, file uploads, AJAX heartbeat calls that mutate session state. Run admin in classic mode and you avoid an entire category of weird bugs.
I've seen a hosting team measure this on a mid-traffic WooCommerce store. PHP-FPM averaged 180ms per product page. Same hardware, same plugins, FrankenPHP worker mode: 28ms. Roughly 6x throughput on the boring read path. Cache-warmed Redis pulled it down further.
RoadRunner is the other option, written in Go. Same idea, slightly different operational model. Pick whichever your team likes more. They both work.
Pattern 3: Object cache via Redis (the one-line plugin install that doubles perf)
WordPress has an internal object cache. By default it's in-memory and lives for one request. Every page load re-queries wp_options, re-fetches user metadata, re-runs the same WP_Query you saw two milliseconds ago.
Drop-in fix: a persistent object cache backed by Redis. The free redis-cache plugin (Till Krüss, maintained, in the official repo) installs a drop-in at wp-content/object-cache.php that hijacks WordPress's cache API and routes it to Redis.
# install via composer (Bedrock setup)
composer require wpackagist-plugin/redis-cache
# activate via WP-CLI
wp plugin activate redis-cache
# write the drop-in
wp redis enable
# check status
wp redis status
Configuration in .env:
WP_REDIS_HOST=127.0.0.1
WP_REDIS_PORT=6379
WP_REDIS_DATABASE=0
WP_REDIS_PREFIX=example_com_prod_
WP_REDIS_MAXTTL=86400
That WP_REDIS_PREFIX is the one nobody sets and everyone regrets. Without it, your staging environment and production environment will fight over the same Redis keys the moment you point both at the same instance. Pick a prefix per environment and forget about it.
The wp redis status command tells you the hit ratio. On a healthy WooCommerce store you'll see >95% after warmup. If you see 60%, something is calling wp_cache_flush() aggressively (older versions of WP Rocket did this on every post save, fixed in 3.16+).
The gotcha: not every plugin uses the cache API correctly. Plugins that hit $wpdb directly bypass the object cache entirely. Query Monitor will show you which ones. The serial offenders are membership plugins and translation plugins that store giant arrays in wp_options.
Plugin compatibility: which plugins break on PHP 8.4 and the workarounds
PHP 8.4 added new deprecation warnings. The big ones for WordPress:
-
E_STRICTremoved: was deprecated in 8.4, removed. Plugins that callederror_reporting(E_ALL & ~E_STRICT)now log a warning. Most have been patched. -
Implicit nullable types deprecated:
function foo(string $bar = null)now wants?string $bar = null. The PHP team gave fair warning; older plugins didn't take it. WPML before 4.6.13 throws deprecation noise. WP Rocket before 3.18 too. -
mb_strlen()returning negative offsets: changed behavior, broke some custom theme regex. Rare but ugly when it hits. -
GD library changes:
imagerotate()and friends behave differently with negative angles. Affects image-heavy galleries and a few photography themes.
The audit pattern:
# log deprecations to a file, don't display them
wp config set WP_DEBUG true --raw
wp config set WP_DEBUG_LOG true --raw
wp config set WP_DEBUG_DISPLAY false --raw
# run a crawl that hits every template
wp cron event run --all
wp eval-file scripts/warm-cache.php
# read the noise
tail -f wp-content/debug.log | grep -i deprecat
When a plugin you can't replace throws PHP 8.4 deprecation warnings but otherwise works, the pragmatic move is to suppress that specific deprecation class globally rather than downgrade PHP. A mu-plugin does it cleanly:
<?php
// web/app/mu-plugins/suppress-php-84-deprecations.php
// silences deprecations from plugins we can't patch yet (WPML, see #4823)
add_action('plugins_loaded', function () {
if (defined('WP_DEBUG') && WP_DEBUG) {
// never silence in dev. we want to see them
return;
}
error_reporting(error_reporting() & ~E_DEPRECATED & ~E_USER_DEPRECATED);
}, 0);
The named-argument constructor issue from PHP 8.0 still bites occasionally. If a plugin extends WP_Widget and overrides the constructor without calling parent::__construct(), PHP 8.4 will complain louder than 7.4 did. The fix is one line in the plugin, but you have to find which plugin.
Headless WordPress + Next.js: when the rewrite is worth it
Every six months a thread goes around about replacing WordPress with a headless setup. WordPress as a content API (WPGraphQL or the REST API), Next.js as the frontend. The selling points are real: better Lighthouse scores, no PHP in the hot path, frontend team gets to use React without writing themes.
The selling points are also overhyped for most sites. You give up:
- Live preview that authors actually use.
- The Gutenberg block editor rendering exactly as the frontend renders. Blocks become a translation problem.
- Plugin compatibility for anything that injects markup (popups, A/B test plugins, marketing pixels).
- Years of WordPress's mature caching and CDN logic.
The math works when you have a separate frontend team that wants to ship React, and a content team that wants WordPress's editor. It does not work for a five-person agency running 30 brochure sites. For those, FrankenPHP + Redis on the existing WordPress is a 2-hour upgrade with a 5x perf gain. Headless is a 6-month rebuild for the same readable-page latency you'd get from running the existing stack on better infra.
When to stay on 7.4 (rarely, and only short-term)
Two scenarios where staying on 7.4 is defensible:
You have a custom plugin nobody can patch. It's the lookup tool that sits between your editorial team and a 2009-era SOAP API. It works. It's PHP 5.6 code in spirit. If patching it costs more than your hosting costs for two years, run that plugin in a sidecar PHP 7.4 container behind a reverse proxy and run the rest of the site on 8.4.
You're mid-acquisition or mid-redesign and the freeze is real. Lock the stack, document the EOL risk, set a calendar invite for the post-freeze upgrade. Don't pretend the risk doesn't exist.
Anything else and you're paying for shared-host VPS lock-in. PHP 8.4 is faster, safer, and supported through December 2028. The plugin ecosystem caught up two years ago. The runtime story is settled. The only thing left is the work.
If this was useful
Most of WordPress's pain in 2026 isn't the runtime. It's that the codebase grew up around a single global $wp object and a hook system designed before namespaces existed. The patterns above (Composer for dependencies, worker mode for hot paths, Redis for the object cache) take you a long way. When your WordPress site stops being a brochure and starts being an application, the next move is structural: separating domain logic from the WordPress globals so it can be tested, swapped, and reasoned about. That's what Decoupled PHP is about: the architectural layer your codebase reaches for after the framework defaults stop scaling.
What's the oldest plugin still running in your WordPress install, and would you rather patch it or replace it?
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)