Upgrading to PHP 8.4 in Production: A Plan Without Surprises

A safe PHP 8.4 upgrade starts with PHPStan and Rector checks before you touch the server, then runs PHP-FPM 8.4 next to your current version and switches one site at a time, with a one-minute rollback that changes a single Nginx line.
Upgrading to PHP 8.4 on a production server does not require a long maintenance window or a leap of faith. The plan we recommend has four stages. First, scan the code automatically with PHPStan, Rector and PHPCompatibility so you know what will break before it does. Second, install PHP-FPM 8.4 alongside your current version on the same server, which gives you two separate sockets such as php8.3-fpm.sock and php8.4-fpm.sock. Third, test on a staging environment with a copy of the database. Fourth, switch each site individually by editing the fastcgi_pass line in Nginx, then running nginx -t and systemctl reload nginx. Rolling back means reversing that same line, which takes under a minute with no restart and no dropped connections. PHP 8.4 shipped in November 2024 and PHP 8.1 left security support at the end of 2025, so upgrading is now a security matter rather than a nice-to-have. Most Laravel 11 and 12 applications run on 8.4 unchanged, and the real problems usually come from outdated packages in composer.lock or extensions that were removed from core.
What actually changes in PHP 8.4
The new features will not break your code because they are opt-in, but they are worth knowing since you will use them after the upgrade:
- Property hooks: write
getandsetlogic inside the property declaration itself instead of separate getter and setter methods. - Asymmetric visibility: for example
public private(set) string $status, where reading is public and writing is limited to the class. - New array functions:
array_find,array_find_key,array_anyandarray_all. - Method calls on a new object without extra parentheses:
new Invoice()->total(). - A new HTML5 parser through
Dom\HTMLDocument, plusmb_trimfor multibyte strings such as Arabic text.
Here is what might break something or flood your logs with warnings:
| Change | Impact | Fix |
|---|---|---|
Implicitly nullable types deprecated, as in function f(User $u = null) | A deprecation notice on every call | ?User $u = null, which Rector fixes automatically |
| IMAP, OCI8, PDO_OCI and pspell moved to PECL | Fatal error if your code depends on them | Install from PECL or replace them |
E_STRICT and trigger_error with E_USER_ERROR deprecated | Warnings | Replace with exceptions |
| Default bcrypt cost raised from 10 to 12 | Each hash takes about 4 times longer | Fine for security, but watch CPU on login endpoints |
That last change deserves attention. A bcrypt hash at cost 12 takes roughly 200 to 250 milliseconds on a modern core, so if you have an API that verifies passwords on every request you will see CPU climb right away.
Compatibility checks before touching the server
This stage happens on a developer machine or in CI and has nothing to do with the server. Start by telling Composer about the target version so it rejects any package that does not support it:
composer config platform.php 8.4.0
composer update --dry-run
composer why-not php 8.4
The why-not command lists every package blocking the upgrade, and in most projects we have reviewed it comes down to two or three stale packages that need a major version bump. Next, run Rector in dry-run mode so it shows what it would change without touching anything:
// rector.php
return RectorConfig::configure()
->withPaths([__DIR__ . '/app', __DIR__ . '/tests'])
->withPhpSets(php84: true);
vendor/bin/rector process --dry-run
Then run PHPStan with the target version set, since it catches calls to functions that were removed or changed signature:
# phpstan.neon
parameters:
level: 5
phpVersion: 80400
vendor/bin/phpstan analyse
Finally, run the full test suite on PHP 8.4 locally with error_reporting=E_ALL. Every deprecation you see today is a potential fatal error in PHP 9. A typical Laravel project with 300 tests gets through this whole stage in under an hour.
Running two PHP-FPM versions side by side
This is the heart of the plan: do not replace the old version, add the new one next to it. On Ubuntu and Debian the ondrej/php repository ships every version in parallel:
add-apt-repository ppa:ondrej/php
apt update
apt install php8.4-fpm php8.4-cli php8.4-mysql php8.4-redis \
php8.4-mbstring php8.4-xml php8.4-curl php8.4-zip php8.4-intl php8.4-bcmath
systemctl enable --now php8.4-fpm
You now have two FPM services running together, each with its own php.ini, its own OPcache memory and its own socket. Copy the pool settings from /etc/php/8.3/fpm/pool.d/www.conf into the matching 8.4 path, especially pm.max_children, memory_limit and upload_max_filesize, because the new version starts with defaults. Mind your memory: two FPM services with 20 workers each on a 4 GB server can take more than 2 GB, so temporarily lower pm.max_children on whichever version is idle, or set it to pm = ondemand so it does not reserve memory for nothing.
If your site runs on cPanel the same idea exists in MultiPHP Manager, where you pick the PHP version per domain, and it is available on managed cloud hosting without any commands.
Testing on staging
Create a subdomain such as staging.example.sa that points to a copy of the code and a fresh copy of the database, and have its Nginx block use the 8.4 socket. Walk through the critical paths by hand: login, payment, PDF generation, file uploads and heavy reports. Watch /var/log/php8.4-fpm.log and the Laravel log for any new deprecation or warning. Do not forget queue workers and cron jobs, since they run from the CLI rather than FPM, and plenty of upgrades succeed on the web side only to fail in a scheduled task that runs at 3 a.m.
The switch window and a one-minute rollback
Pick a low-traffic moment, then edit the site's Nginx block:
# before
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
# after
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
nginx -t && systemctl reload nginx
A reload does not interrupt in-flight requests, since old workers finish their requests while new ones use the new socket. Then switch the CLI and restart the workers:
update-alternatives --set php /usr/bin/php8.4
php artisan queue:restart
php artisan optimize
Watch three numbers for the first 15 minutes: the 5xx rate in the Nginx log, response time, and the size of the error log. If something goes wrong, rolling back is the same two lines reversed plus a reload. The old version is still running with a warm OPcache, so the rollback finishes in under 60 seconds. Keep the old version installed for at least two weeks before removing it, because some issues only appear in a report that runs once a week.
A practical example after the upgrade: property hooks instead of getters
Once you are settled on 8.4 you can simplify a lot of code. Take an invoice class that computes 15% VAT and blocks status changes from outside. It used to need a private property and two methods per value, and now it looks like this:
final class Invoice
{
public private(set) string $status = 'draft';
public function __construct(public float $subtotal) {}
public float $total {
get => round($this->subtotal * 1.15, 2);
}
public function markPaid(): void
{
$this->status = 'paid';
}
}
Writing $invoice->status = 'paid' from outside the class throws an error immediately, and total is computed on read so it never holds a stale value. Do not rush to rewrite everything in this style, though. Upgrading and refactoring are separate stages, and combining them in one deploy makes any bug much harder to trace. Ship the upgrade alone, then use the new features in new code over the following weeks.
OPcache and JIT on the new version
Each FPM version holds its own OPcache memory, so the first requests after the switch are slightly slower while files are compiled and cached. In a typical Laravel app with around 4,000 PHP files this takes 2 to 5 seconds of normal traffic. Make sure opcache.memory_consumption is at least 256 and opcache.max_accelerated_files is at least 20,000 in the 8.4 config, because the defaults are smaller than most production apps need. Leave JIT disabled for regular web apps, since its gains show up in heavy computation rather than database-bound requests.
What this costs in resources
Running both versions during the transition needs between 300 MB and 1 GB of extra memory depending on worker counts. On a cloud server on the Business plan (4 vCPU and 8 GB) at 11,500 SAR per year that is comfortable headroom. On the 2 GB Starter plan you are better off switching sites quickly or adding 2 GB of RAM for 600 SAR per year during the transition. Plans and add-ons are on the pricing page.
Short execution checklist
- Set
platform.phpto 8.4.0, runcomposer why-not php 8.4and upgrade the blocking packages. - Run Rector with
--dry-runand PHPStan withphpVersion: 80400, then fix the findings. - Run the full test suite on 8.4 with every deprecation visible.
- Install
php8.4-fpmnext to the current version and copy the pool settings. - Test on staging, including queues and cron.
- Switch one site, watch it for 15 minutes, then move to the next.
- Remove the old version after two clean weeks.


