I've been managing VPS environments for over 15 years. And the one thing that never changes is how many production servers I inherit running PHP versions that should have been retired years ago.
Last year, I took over a client's server still running PHP 7.0, a version that hit end-of-life in 2019. It still worked. That's actually the problem.
The upgrade process isn't hard. But the cost of ignoring it is real, and it compounds quietly.
This guide covers upgrading PHP on a Debian or Ubuntu VPS all the way up to PHP 8.4 or 8.5, with notes on what changed, what to watch for, and a few things older guides get wrong.
Before You Upgrade: Know Your Codebase
This guide targets PHP 8.4 and 8.5. But not every app can make that jump in one shot, and running a blind upgrade on a legacy codebase is a reliable way to take a site down.
I've seen it plenty of times. A server running PHP 5.6 with a CodeIgniter 3 app that was built in 2014 and hasn't been touched since. Or a Drupal 7 install. Or a WordPress site with plugins that predate PHP 7.0. These apps work fine on the version they were built for. They don't always survive a major version jump. Be careful and know your code!
PHP 5.6 and 7.x are still running on thousands (if not millions) of production servers.
If your codebase is one of them, audit before you upgrade. A few hard stops to check for:
mysql_*functions: removed entirely in PHP 7.0. Any code usingmysql_connect(),mysql_query(), or similar will throw a fatal error. Migrate tomysqli_*or PDO first.ereg()and related POSIX regex functions: also removed in PHP 7.0. Replace withpreg_*equivalents.mcryptas a hard dependency: gone from core in 7.2. The Sury package keeps it available, but if it's wired into your app tightly, plan the migration to OpenSSL.- Call-time pass-by-reference (
foo(&$var)): removed in PHP 7.0. - Short open tags (
<?) without the=behavior changed in 7.0, can break template-heavy codebases.
CodeIgniter 3 officially supports PHP 5.6 through 7.4, but in practice it runs mostly fine on PHP 8.2. The main friction points are deprecation notices that surface as errors in strict configurations, and a handful of compatibility issues depending on which CI3 version and libraries you're running. PHP 8.3 and above get shakier. If you're on CI3 and targeting 8.2, test thoroughly in staging but don't treat it as a hard blocker. You can update your composer dependency to pocketarc/codeigniter and continue to run CI3.
Drupal 7 reached end-of-life in January 2025. It supports up to PHP 7.4 and will break on PHP 8.x. If you're still running Drupal 7, the PHP upgrade conversation is the same conversation as the Drupal migration.
The staged upgrade reality. Some servers need to go 5.6 to 7.4 first, verify everything runs, then 7.4 to 8.x in a second pass. That's not failure. That's the right approach for legacy apps. The commands in this guide work the same regardless of which version jump you're making. Just substitute the version numbers.
If your app is on a modern framework or a recent CMS version, you're probably fine to jump straight to 8.4 or 8.5. But check first. A staging environment saves you hours of production recovery.
Running Outdated PHP Is a Security Problem, Not Just a Performance One
Performance is the easy sell. PHP 8.x is dramatically faster than anything in the 7.x branch. But the more pressing reason to upgrade isn't speed. It's exposure.
Every PHP version has a defined support window: two years of active bug fixes, followed by one year of security-only patches. After that, nothing. No CVE responses. No patches. Any vulnerability discovered after end-of-life stays unpatched on your server permanently.
According to W3Techs, over 60% of publicly accessible PHP sites were running end-of-life versions as of early 2025, with PHP 7 alone powering nearly half of all PHP websites despite losing security support in November 2022. Sixty percent. That's not theoretical risk. That's a documented attack surface at massive scale.
Running an end-of-life PHP version in production isn't a technical debt issue. It's a liability issue. The question isn't if something will go wrong, it's when.
The attack vectors are real and well-documented: remote code execution via unpatched deserialization bugs, type juggling exploits, object injection in older extension versions. These aren't exotic CVEs sitting in academic papers. They're weaponized and actively scanned for.
PHP 7.4 hit end-of-life in November 2022. PHP 8.0 followed in November 2023. PHP 8.1 ended active support in 2024 with security-only patches through December 2025.
Still running any of those? You're already past the line, or close to it.
The Current PHP Support Landscape
Before touching anything, know where your server stands. Here's the picture as of early 2026:
| Version | Status | EOL Date |
|---|---|---|
| PHP 5.6 | EOL | December 2018 |
| PHP 7.3 | EOL | December 2021 |
| PHP 7.4 | EOL | November 2022 |
| PHP 8.1 | EOL | December 2025 |
| PHP 8.2 | Active | December 2026 |
| PHP 8.3 | Active | December 2027 |
| PHP 8.4 | Active | December 2028 |
| PHP 8.5 | Current latest | December 2027 (active) |
PHP 8.4 is the right call for most existing applications. Battle-tested, widely supported across frameworks and CMS platforms, and it gives you the longest runway before you're doing this again.
PHP 8.5 makes sense for greenfield builds, but test your existing apps before upgrading blind. And if you're still on 8.1, it's EOL as of December 2025. Get off it.
Your CMS Has Requirements
CMS and framework minimum requirements have been moving forward, and some of them will force your hand whether you're ready or not.
WordPress
WordPress's official minimum is PHP 7.2, but that's a floor, not a recommendation. WordPress 6.8+ was built and tested against PHP 8.3 and 8.4. Running WordPress on 8.1 technically works. But you're leaving real performance gains behind and your EOL clock is already running.
Craft CMS
Craft 5 requires PHP 8.2 minimum. Still on Craft 4? The floor is PHP 8.0, but that version is coming up to end-of-life, so you need 8.2 or higher regardless of which Craft version you're running. If you're on Craft 4 running PHP 8.0, you have two upgrades to plan, not one. Craft 6 is currently in alpha and migrating to Laravel under the hood. Final PHP requirements haven't been published yet, but plan on 8.2 at minimum given Laravel's own floor.
CodeIgniter
CodeIgniter 4.7 raised its minimum to PHP 8.2 and added full PHP 8.5 compatibility. Running CI on PHP 8.5 requires 4.7.0 or higher. But the floor is 8.2, not 8.5.
Laravel
Laravel 11 requires PHP 8.2 minimum. Laravel 12, released early 2025, also requires 8.2 but is optimized for 8.3 and 8.4. Running a Laravel application below 8.2 means you can't upgrade the framework without upgrading PHP first. One problem creates another.
Before You Touch Anything: Backup!
This should go without saying. I've watched developers skip this step and spend hours recovering from it. SSH into your server and back up your current PHP configuration before running a single command:
cp -a /etc/php/8.x /home/your_username/php_8x_backupReplace 8.x with your currently installed version. If you're running PHP-FPM with per-site pool configurations, those files won't survive the upgrade automatically. Document them. Copy them. You'll need to manually recreate them under the new version. This will backup your pools and php.ini, allowing a somewhat quick rollback, if needed.
Add the Sury Repository
The ondrej/sury PPA is the standard source for current PHP packages on Debian and Ubuntu. Well-maintained, actively updated, and covers every PHP version you'd need.
On Debian/Ubuntu (from official docs):
apt update
apt install -y apt-transport-https ca-certificates curl
DISTRO=$(dpkg --status tzdata | grep Provides | cut -f2 -d'-')
if [ "${DISTRO}" == "forky" ]; then DISTRO="bookworm"; fi
curl -sSLo /usr/share/keyrings/deb.sury.org-php.gpg https://packages.sury.org/php/apt.gpg
echo "deb [signed-by=/usr/share/keyrings/deb.sury.org-php.gpg] https://packages.sury.org/php/ ${DISTRO} main" > /etc/apt/sources.list.d/deb.sury.org.list
apt updateThe DISTRO detection pulls your release codename from tzdata metadata rather than relying on lsb_release, which may not be installed on minimal server builds. The forky fallback maps Debian's testing branch to bookworm packages since a native forky repo isn't available yet.
Once the repository is added, check what's currently installed:
dpkg --get-selections | grep phpNote everything on that list. You'll want to mirror the same extensions on the new version, minus a few that have been removed or renamed (covered below).
Installing PHP 8.5
Here's a solid baseline install command for a typical Nginx + PHP-FPM stack targeting PHP 8.5. Adjust to match your extension list from the dpkg output above.
apt install php8.5-bcmath php8.5-cli php8.5-common php8.5-curl php8.5-dev \
php8.5-fpm php8.5-gd php8.5-gmp php8.5-igbinary php8.5-imagick \
php8.5-intl php8.5-maxminddb php8.5-mbstring php8.5-mcrypt \
php8.5-memcached php8.5-msgpack php8.5-mysql php8.5-readline \
php8.5-soap php8.5-tidy php8.5-xml php8.5-xsl php8.5-zipA few notes on what's in this list and why:
- bcmath: required by Laravel, WooCommerce, and anything doing arbitrary precision math. Not optional if you're running any of those.
- dev: only needed if you're compiling PECL extensions manually. Safe to drop if you're not.
- imagick: native ImageMagick bindings. Required for image processing in WordPress, Craft, and most thumbnail pipelines.
- intl: required by Craft CMS, Symfony, and Laravel internals for internationalization support. Add it regardless of whether your app explicitly asks for it.
- maxminddb: the native extension for MaxMind GeoIP2 databases, replacing the old
geoipextension dropped in PHP 8.0. Thegeoip2Composer package works too, but the native extension performs better. - mcrypt: removed from PHP core in 7.2, but it lives on as a PECL/Sury package and is still installable via the
ondrej/phprepo. If your codebase depends on it, it's available. Migrate off it when you can. OpenSSL covers every use case mcrypt handled and then some. - json: baked into PHP 8.0 core. No separate package needed or available.
- opcache: statically compiled into PHP 8.5. No separate package needed. On by default.
Switching PHP-FPM Versions
Here's the thing older guides gloss over: restarting PHP won't automatically switch your active version. Stop the old one and start the new one explicitly.
service php8.3-fpm stop
service php8.5-fpm startThen update your Nginx site configurations to point to the new socket:
fastcgi_pass unix:/run/php/php8.5-fpm.sock;If you're running php pools, that above might look more like {username}_fpm.sock, so update as needed. More on pools below.
Test and reload:
nginx -t && systemctl reload nginxVerify with a quick phpinfo() call in a temporary file. Then delete it immediately. Don't leave phpinfo() files sitting on production servers. They expose your entire environment configuration to anyone who finds them.
Migrating Your PHP Configuration
Don't copy your old php.ini directly over the new one. PHP minor versions frequently change defaults or deprecate directives, and blindly overwriting will cause unexpected behavior. Compare them instead:
diff /etc/php/8.3/fpm/php.ini /etc/php/8.5/fpm/php.iniPort your custom values manually. The settings worth double-checking every upgrade: memory_limit, upload_max_filesize, post_max_size, max_execution_time, and date.timezone.
Also review /etc/php/8.5/fpm/php-fpm.conf and your pool configs in /etc/php/8.5/fpm/pool.d/. If you had per-site pools configured, recreate them here. You will need to move your pools from your prior PHP install directory.
A php.ini Starting Point for PHP 8.4 and 8.5
After porting your settings manually, here's a production-ready configuration worth using as a baseline.
Drop this into your /etc/php/8.5/fpm/php.ini and adjust the values to match your server's resources and use case.
Btw, you don't need to review your php.ini file line-by-line. You can simply append this to the bottom, as new values will override any prior values up the file.
realpath_cache_size = 4M
realpath_cache_ttl = 300
max_execution_time = 180
memory_limit = 1G
html_errors = On
error_log = error_log
max_input_vars = 10000
post_max_size = 120M
upload_max_filesize = 120M
max_file_uploads = 50
default_socket_timeout = 90
date.timezone = America/Chicago
sendmail_path = '/usr/sbin/sendmail -t -i -fnoreply@yourdomain.com -Fnoreply@yourdomain.com'
mysqli.allow_local_infile = On
session.use_strict_mode = 1
session.cookie_secure = 1
session.gc_probability = 5
session.gc_divisor = 100
session.gc_maxlifetime = 7200
session.sid_length = 32
opcache.enable = 1
opcache.enable_cli = 0
opcache.memory_consumption = 1024
opcache.interned_strings_buffer = 256
opcache.max_accelerated_files = 30000
opcache.max_wasted_percentage = 5
opcache.use_cwd = 1
opcache.validate_timestamps = 1
opcache.revalidate_freq = 0
opcache.jit_buffer_size = 1G
opcache.jit = tracingGeneral performance. realpath_cache_size and realpath_cache_ttl reduce filesystem stat calls by caching resolved file paths. On servers running a CMS or framework with deep include chains, this has a measurable impact. max_accelerated_files = 30000 covers large codebases comfortably. WordPress with WooCommerce and a handful of plugins can easily hit 10,000+ cached scripts.
Memory and uploads. memory_limit, post_max_size, and upload_max_filesize should be set in proportion to each other. post_max_size must always be higher than upload_max_filesize or large uploads will fail silently. The values above suit a CMS environment handling media uploads. Scale down for smaller VPS instances.
Sessions. session.use_strict_mode and session.cookie_secure are the two most important security settings here. Strict mode rejects uninitialized session IDs, preventing session fixation attacks. Cookie secure requires HTTPS. Enable it without a valid certificate and sessions will break. gc_maxlifetime = 7200 gives sessions a two-hour lifetime, with a 5% probability of garbage collection firing on any given request.
OPcache. opcache.validate_timestamps = 1 with revalidate_freq = 0 tells PHP to check for file changes on every request. Right for development and staging. In a strict production environment where deploys are controlled, setting validate_timestamps = 0 eliminates those filesystem checks entirely and squeezes out additional performance, but you'll need to manually clear the cache on deploy. opcache.interned_strings_buffer = 256 is well within the limits PHP 8.4 raised. The old 4095MB cap was lifted, making larger values viable on modern servers.
JIT. opcache.jit = tracing is the recommended mode for web applications. It traces hot code paths across the full call stack and compiles them to native machine code at runtime. jit_buffer_size is what actually activates JIT. Without it set to a non-zero value, JIT won't run regardless of the opcache.jit setting. 1G is appropriate for a dedicated server with significant PHP workloads. Scale to 128M or 256M on lighter instances.
A php-fpm.conf Baseline
The global php-fpm.conf controls FPM's master process behavior. Most of it can stay at defaults.
Find these lines in /etc/php/8.5/fpm/php-fpm.conf and update their values. These cannot be appended to the bottom, like the php.ini file.
log_level = warning
emergency_restart_threshold = 20
emergency_restart_interval = 1m
process_control_timeout = 10s
events.mechanism = epollLog level. Default is notice, which is noisy. warning keeps the log actionable without burying real problems in routine chatter.
Emergency restart. If 20 child processes crash within a minute, FPM restarts automatically. This protects against cascading failures from a corrupted opcode cache or bad deploy without requiring manual intervention.
Process control timeout. Gives child processes 10 seconds to respond to signals from the master before the master considers them unresponsive. The default of 0 means no timeout, which can leave zombie workers sitting indefinitely.
epoll. Linux's most efficient I/O event mechanism. The default is auto-detection, but setting it explicitly avoids any ambiguity on systems where multiple mechanisms are available.
A www.conf Pool Baseline
The pool config in /etc/php/8.5/fpm/pool.d/www.conf is where the real performance tuning lives. Append these to the bottom:
user = www-data
group = www-data
listen = /run/php/php8.5-fpm.sock
listen.owner = www-data
listen.group = www-data
pm = dynamic
pm.max_children = 25
pm.start_servers = 5
pm.min_spare_servers = 3
pm.max_spare_servers = 10
pm.max_requests = 500
request_terminate_timeout = 180sIf you're running multiple pools and configs, you can customize pools in this same directory, and I like to name them by website. So, I'll have a pool named: /etc/php/8.5/fpm/pool.d/zadroweb.com.pool.conf and so on, per site.
Socket vs TCP. Unix sockets (/run/php/php8.5-fpm.sock) are faster than TCP for same-server communication. Nginx talks to PHP over the socket directly without going through the network stack.
pm = dynamic. The right choice for most web servers. static wastes memory by keeping all workers alive whether you need them or not. ondemand is too slow to spin up under traffic spikes. Dynamic keeps a pool of idle workers ready while staying within your memory ceiling.
Sizing pm.max_children. This is the most important value in the file, and the default of 5 is almost certainly wrong for your server. The rough formula: divide available RAM (after OS and other services) by your average PHP process size. Check your current process size with:
ps --no-headers -o "rss,cmd" -C php-fpm8.5 | awk '{ sum+=$1 } END { printf "%d MB\n", sum/NR/1024 }'A CMS stack typically runs 50-100MB per process. On a 4GB VPS with 2GB available for PHP, that's 20-40 workers. The values above are a reasonable starting point for a mid-range server, but adjust based on your actual memory.
pm.max_requests = 500. Forces each worker to restart after 500 requests. This is your safety net against slow memory leaks in third-party extensions or poorly written plugins. It adds a small overhead but prevents the gradual memory creep that eventually takes a server down at 2am.
request_terminate_timeout = 180s. Hard kills any worker that's been running for 3 minutes. Matches max_execution_time in php.ini. Without this, a runaway script can hold a worker hostage indefinitely regardless of what php.ini says — some code paths bypass max_execution_time entirely.
Clean Up the Old Version
Once you've confirmed the new version is running cleanly and your sites are responding correctly, remove the old packages:
apt purge php8.3-common
apt autoclean && apt autoremove
rm -rf /etc/php/8.3/Run the dpkg command again to confirm nothing is left behind. Occasionally a transitive package from an older version hangs around and needs a manual purge.
The Bottom Line
Running EOL PHP in production isn't a "we'll get to it" situation. It's an active liability with no upstream coverage. The upgrade path on Debian and Ubuntu is well-worn at this point. The Sury repository handles package management cleanly, and the main friction is always application compatibility testing, not the upgrade mechanics themselves.
Test in staging. Review your extension list. Port your ini settings manually. Then make the switch.
If you'd rather hand this off entirely, our managed hosting keeps your stack current as a standard part of the service, not an upsell.
Have questions or ran into something this guide didn't cover? Reach out.