End-of-Life PHP, and How to Stop Running It: A Patching and Lifecycle Guide

Every time a critical PHP vulnerability makes the rounds — most recently the PHP2Shell vulnerability — the same line shows up in the advisory: end-of-life branches get no official fix. This post covers both halves of that problem: what PHP’s end-of-life process actually means and why EOL branches stay in production for years past their support window, and how to build a repeatable patch workflow so staying current isn’t a fire drill every time a new CVE lands.

What “end-of-life” means for PHP

Each PHP branch (8.4, 8.3, 8.2, and so on) goes through two support phases before it is retired:

  • Active support (2 years) — bug fixes and security fixes.
  • Security support (1 additional year) — security fixes only, no bug fixes.
  • End of life — no fixes of any kind from the PHP project, for any issue, including remotely exploitable ones.

That gives every branch roughly three years of upstream support from its initial release. After that date passes, the PHP project will not backport a patch for a new CVE to that branch, no matter how severe. The 8.1.2-1ubuntu2.28-style version strings you see in distro changelogs are downstream backports from a vendor’s own security team, not upstream PHP continuing to maintain the branch — once a branch is EOL upstream, every fix you get from then on comes from your distro, not the PHP project.

Current PHP branch status

BranchInitial releaseActive support endsSecurity support ends
8.5Nov 2026Nov 2028Nov 2029
8.4Nov 2024Nov 2026Nov 2027
8.3Nov 2023Nov 2025Nov 2026
8.2Dec 2022Dec 2024Dec 2025
8.1Nov 2021Nov 2023Nov 2024 (EOL)
8.0Nov 2020Nov 2022Nov 2023 (EOL)
7.4Nov 2019Nov 2021Nov 2022 (EOL)

Branches 7.3 and earlier, including the entire 5.x and 7.0–7.2 lines, have been EOL for years. If php -v on one of your hosts reports anything in that range, it is not receiving fixes from upstream PHP under any circumstance.

Why EOL PHP keeps running in production

EOL branches rarely survive by decision — they survive by default. The usual causes:

  • Framework and plugin lock-in. A WordPress plugin, a Laravel app on an old major version, or an in-house codebase that calls deprecated functions removed in later PHP versions blocks the upgrade path until someone budgets time to fix the calling code.
  • Distro-extended support masking the real status. Debian LTS, Ubuntu ESM, and RHEL’s extended lifecycle backport security fixes onto EOL PHP branches for years past their upstream EOL date. That buys time, but it is not the same as the branch being supported — those distro backports only cover issues the vendor chooses to patch, and coverage quietly thins out as the branch ages.
  • No ownership. On servers nobody is assigned to, PHP upgrades fall under “if it isn’t broken” until an incident like PHP2Shell makes it broken.
  • Fear of breaking changes. Each major PHP version removes deprecated functionality (e.g. mysql_* functions removed in 7.0, dynamic class properties deprecated in 8.2). Teams defer the upgrade rather than spend the time auditing for breakage.

Finding EOL PHP across your fleet

Before you can retire anything, you need an inventory. On each host:

php -v
php -i | grep -i 'php version'
rpm -qa 'php*' 2>/dev/null || dpkg -l 'php*' 2>/dev/null

If you are managing more than a handful of hosts, script the collection instead of checking each one by hand — an Ansible ad-hoc command or a one-line SSH loop over your inventory will surface every branch in use far faster than logging into each box:

ansible all -m shell -a "php -v | head -1"

A single php -v is not sufficient on hosts using Remi’s versioned packages. The remi-safe repository is designed to let multiple PHP branches coexist side by side — each versioned package (e.g. php74-php-fpm, php81-php-fpm, php82-php-fpm) installs its own binaries under /opt/remi/phpNN/ and runs as its own service, independently of whichever version /usr/bin/php points to. php -v only reports that one default binary; it says nothing about the other installed branches, and says nothing about which of them are actually enabled and serving requests. On a Remi host, enumerate every installed version and every running instance separately:

rpm -qa 'php[0-9][0-9]-php-fpm' 'php[0-9][0-9]-php-cli'
for v in /opt/remi/php*/root/usr/bin/php; do echo "$v:"; "$v" -v | head -1; done
systemctl list-units --all 'php*-fpm*'

The first command lists every versioned branch installed on the box; the second prints the actual version string for each one (don’t assume the package name’s version suffix matches the patch level); the third shows which of those FPM instances are enabled or actively running, independent of which one the default php command invokes. A branch can be both installed and actively serving traffic through its own FPM pool while remaining completely invisible to a plain php -v check.

Cross-reference every version you find — from every one of these commands, not just the default binary — against the table above. Anything past its security support end date is running without an upstream safety net, whether or not it’s the version php -v happens to report.

Getting off an EOL branch

  1. Target the nearest supported branch, not the newest. Jumping from 7.4 straight to 8.5 compounds every breaking change across five major versions at once. Move one or two majors at a time and test at each step.
  2. Run the branch’s migration guide. The PHP manual publishes a “Backward Incompatible Changes” page for every major version — read the one for each version you are passing through, not just the destination.
  3. Use static analysis to find breakage before runtime does. Tools such as PHPCompatibility (a PHP_CodeSniffer ruleset) flag calls to removed or deprecated functions against a target PHP version without executing the code.
  4. Stand up the new version in parallel, not in place. Run the target PHP version alongside the old one (separate FPM pool, separate container, or a canary vhost) and route a slice of traffic to it before cutting over fully.
  5. Re-point package management at a maintained source if your distro’s default repo lags. RHEL-family systems commonly rely on the Remi repository for current PHP versions; Debian and Ubuntu’s default repos track one branch per release, so a distro upgrade may be required to reach a newer branch.

Once you’re current: a repeatable patch workflow

Landing on a supported branch solves today’s problem. Keeping it supported is an ongoing task. The pattern that keeps a routine patch cycle from turning into an incident response is: know your branch, hear about advisories quickly, patch by a known-good procedure, and verify the fix actually landed.

Know your branch and its support window

Check the running version and cross-reference it against the PHP supported versions page, noting the branch (the first two numbers, e.g. 8.3), not just the full version string — security fixes apply per branch, and a branch past its security support end date will not receive a fix for a new CVE regardless of how recent its last patch was:

php -v

On hosts running multiple Remi-packaged branches side by side, php -v only covers the default binary — see the fleet-inventory commands above for enumerating every installed and running version before you decide what needs patching.

Subscribe to the sources that announce PHP security releases

You want to hear about a critical PHP CVE within hours, not when a scanner flags it weeks later:

  • PHP’s own security advisories — the php.net front page and the php-announce mailing list cover every security release.
  • Your distro’s security advisory feed — Debian Security Advisories (DSA) and Long Term Support Advisories (DLA), Ubuntu Security Notices (USN), and RHEL/AlmaLinux/Rocky errata (ELSA/RHSA) all publish per-package advisories you can subscribe to or pull via dnf updateinfo.
  • CISA’s known exploited vulnerabilities catalog — flags when a CVE moves from “patch when convenient” to “patch today” because active exploitation has been confirmed.

Patching, by distribution

The commands differ by distro, but the pattern is the same: refresh metadata, upgrade the PHP packages, restart the services that embed PHP.

RHEL, AlmaLinux, Rocky Linux (8, 9, 10):

sudo dnf clean metadata
sudo dnf upgrade 'php*'
sudo systemctl restart php-fpm httpd

List pending security errata specifically, rather than upgrading everything, if you want to patch PHP in isolation:

sudo dnf updateinfo list security | grep -i php

If you installed PHP from the Remi repository (common on RHEL-family boxes to get a PHP version newer than the distro default), package names are prefixed per version, e.g. php84-php-fpm:

sudo dnf upgrade 'php84-*'
sudo systemctl restart php84-php-fpm

Debian:

sudo apt update
sudo apt install php
sudo systemctl restart php-fpm apache2

Debian ties its PHP branch to the release (bookworm ships 8.2, trixie ships 8.4). A distro upgrade, not just a package upgrade, is required to move to a newer branch. Debian LTS extends security coverage for the previous stable release; check whether your release is still inside that window.

Ubuntu:

sudo apt update
sudo apt upgrade
sudo systemctl restart php-fpm apache2

As with Debian, the PHP branch is tied to the Ubuntu release. Older LTS releases (14.04, 16.04) only continue receiving PHP security fixes under Ubuntu Pro with esm-infra enabled — verify that subscription is active if you depend on it.

Automating the check, without automating the restart

Checking for pending PHP security updates is safe to automate; applying them to a production web server mid-request is not. A reasonable split:

  • Automate detection. A daily cron job or CI check that runs dnf updateinfo list security / apt list --upgradable and alerts (Slack, email, a monitoring check) when a PHP package has a pending security update.
  • Automate staging rollout. Apply the update to a staging or canary host automatically, run your test suite against it, and only then queue the production rollout.
  • Keep production patching a deliberate, scheduled action — a maintenance window or a rolling restart across your pool — so a patch doesn’t land mid-traffic-spike and so you control the order services restart in.

Verifying a patch actually landed

Most enterprise distros backport the security fix into the existing package version rather than bumping the upstream version number, so php -v can keep showing the same string before and after you’ve patched. Don’t use the version string alone to confirm a fix:

  • Check the package’s installed version against the distro advisory’s “fixed in” version, e.g. rpm -q php or dpkg -s php8.2-fpm | grep Version compared against the advisory.
  • Read the advisory’s changelog entry for the CVE ID directly — it will name the exact package version that contains the fix.
  • For a configuration-level mitigation (rather than a package patch), confirm the live setting with php -i | grep or phpinfo() against a running worker, not just the config file on disk — a worker that hasn’t restarted is still running the old setting.

Summary

  • PHP branches get roughly three years of upstream support total, then no fixes of any kind, for any severity.
  • Distro-extended support (Debian LTS, Ubuntu ESM, RHEL ELS) can mask how old a branch really is — check the upstream EOL date, not just whether patches are still arriving.
  • Inventory every host’s PHP version, prioritize anything past security support end-of-life, and migrate one major version at a time with static analysis and parallel testing.
  • Once current, know your branch’s support window, subscribe to advisory feeds, automate detection of pending updates, and verify fixes against the distro advisory rather than an unchanged version string.
  • Treat every PHP2Shell-style incident as a forcing function, not a one-off, until EOL branches are actually gone from your fleet and patching is routine rather than reactive.

Sources

Recover Deleted MegaRAID Volume with Linux

Recently a customer with a 4-disk RAID5 array backed by a MegaRAID controller came to us because their RAID volume was missing. The virtual disk (VD) exported by the RAID controller had disappeared!

The logs indicated that it was deleted, but as far as we can tell no one was on the system at the time. Maybe this was a hardware/firmware error, or user error, but either way the information that defines the RAID volume was no longer available.

We were able to use the Linux RAID 5 module to recover the missing data!  Read on:

Hardware RAID Volumes

When a volume is deleted, the RAID controller removes the volume descriptor on the physical disks but it does not destroy the data itself; the data is still there. There are ways to recover the data using the controller itself. MegaRAID volume recovery documentation suggests that you to re-create the volume using the same parameters and disk order as the original RAID. For example, if you know that it was a 256k stripe then you can recreate the array with the same disk ordering and same stripe size. However, for a raid volume that has been in service for years, how can this possibly be known unless someone wrote it down?

While the controller would then stamp the disks with a new header and leave the data alone, theoretically there will be no data loss and the array will continue as it had originally. This procedure comes with quite a bit of risk. if the parameters are off then you can introduce data corruption. Thus, you should back up each disk individually as a raw full disk image. Unfortunately this takes a long time and as far from convenient. If you get the parameters wrong then the data should be restored before trying again to guarantee consistency, and that takes even longer.

Guarantee Recovery Without Data Loss

Recovery in place was the only option because it would take too long to do full backups during each iteration. However, we also had to guarantee that the recovery process would not introduce failures that could lead to data corruption. For example, if we had chosen a 64k stripe size but it was formatted with a 256k stripe size, then the data would be incorrect. While it is probably safe to try multiple stripe sizes without initialization by the raid controller, there is the risk of technician error causing data loss during this process.

You should always avoid single-shot processes with the risk of corrupting data. In this case terabytes of scientific data were at riskand I certainly did not trust the opaque behavior of a hardware raid controller card that might try to initialize an array with invalid data when I did not want it to. Using a procedure provided by the RAID controller making several attempts, each with an additional risk of losing data, is unnerving to say the least!

I would much prefer a method that is more likely to succeed, and certainly with the guarantee that it is impossible to lose data. When working with customer data it is imperative that every test we make along the way during the process of data recovery is guaranteed not to make things worse.

How to use Linux for RAID Recovery

This is where Linux comes in: using the Linux loopback driver we can require read-only access to the disks. By using the Linux dm-raid5 module we can attempt to reconstruct the array by guessing it’s parameters. This allows us a limitless number of tries and the guarantee that the process of trying recover the data never cause corruption, whether or not it succeeds.

First we started by exporting the disks on the RAID controller as jbod volumes. This allows Linux to see the raw volumes as they were without being modified by the RAID controller firmware:

storcli64 /c0 set jbod=on

Once the drives are available to the operating system as raw disks, we used the Linux loopback driver to configure them as read only volumes.  For example:

losetup --read-only /dev/loop0 /dev/sdX
losetup --read-only /dev/loop1 /dev/sdY
losetup --read-only /dev/loop2 /dev/sdZ
losetup --read-only /dev/loop3 /dev/sdW

Now that the volumes are read only we can attempt to construct them using the Linux RAID 5 device mapper target. There are several major problems:

  1. We do not know the stripe size
  2. We do not know the on-disk format used by the RAID controller.
  3. Worse than that, we do not know the disk ordering that the RAID controller selected when it built the array.

This is makes for quite a few unknown variables–and only one combination will be correct. In our case there were only 4 disks so the possible disk ordering is 24 (4! = 4*3*2*1 = 24).

Now it is a matter of trial and error and we can use the computer to help us a bit to minimize the amount of typing we have to do, but there is still manual inspection to review and make sure that the ordering it found is useful and correct:

[0,1,2,3], [0,1,3,2], [0,2,1,3], [0,2,3,1], [0,3,1,2], [0,3,2,1],
[1,0,2,3], [1,0,3,2], [1,2,0,3], [1,2,3,0], [1,3,0,2], [1,3,2,0],
[2,0,1,3], [2,0,3,1], [2,1,0,3], [2,1,3,0], [2,3,0,1], [2,3,1,0],
[3,0,1,2], [3,0,2,1], [3,1,0,2], [3,1,2,0], [3,2,0,1], [3,2,1,0]

We permuted all possible disk orderings and passed them through to the `dm-raid` module to create the device mapper target:

dmsetup create foo --table '0 23438817282 raid raid5_la 1 128 4 - 7:0 - 7:1 - 7:2 - 7:3'

For each possible permutation, we used `gfdisk` to determine if the partition table was valid and found that disk-3 and disk-1 presented a valid partition table if they were the first drive in the list; thus, we were able to rule out half of the permutations in a short amount of time:

gdisk -l /dev/foo
...
Number Start (sector) End (sector) Size Code Name
1 2048 23438817279 10.9 TiB 0700 primary

we did not know the exact sector count of the original volume, so we had to estimate based on the ending sector reported by gdisk. We knew that there were 3 data disks so the sector count had to be a multiple of three.

The next step was to use the file system checker (e2fsck / fsck.ext4) to determine which permutation has the least number of errors. Many of the permutations that we tested fail the minute immediately the file system checker did not recognize the data at all. However, in a few of the permutations the file system checker understood the file system enough to spew 1000s of lines of errors on the screen. We knew we were getting closer, but none of the file system checks seemed to complete with a reasonable number of errors. This caused us to speculate that our initial guess of a 64k stripe size was incorrect. The next stripe size we tried was 256k and we began to see better results. Again many of the file system checks failed altogether but the file system checker seems to be doing better on some of the permutations. however, it still was not quite right. We had only been trying the default raid5_la module format, but the dm-raid module has the following possible formats:

  • raid5_la RAID5 left asymmetric – rotating parity 0 with data continuation
  • raid5_ra RAID5 right asymmetric – rotating parity N with data continuation
  • raid5_ls RAID5 left symmetric – rotating parity 0 with data restart
  • raid5_rs RAID5 right symmetric – rotating parity N with data restartwe added a 2nd loop to test every raid format for every permutation and when it reached raid5_ls on the 23rd permutation, the file system checker became silent and took a very long time. Only rarely did it spit out a benign warning about some structure problem that it found which was probably already in the valid RAID array to begin with. We had found the correct configuration to recover this raid volume!While we had to figure this out initially by trial and error using a simple Perl script to discover the configuration, you know that the MegaRAID controller uses the raid5_ls RAID type. This was the correct configuration for our drive:
  • RAID disk ordering: 3,2,0,1
  • RAID Stripe size: 256k
  • RAID on-disk format: raid5_ls – left symmetric: rotating parity 0 with data restart

Now that the raid volume was constructed we wanted to test to make sure it would mount and see if we can access the original data. Modern file systems have journals that will replay at mt time and we needed to keep this read only because a journal replay of invalid data could cause corruption. Thus, we used the “noload” option while mounting to prevent replay:

mount -o ro,noload /dev/foo /mnt/tmp

The volume was read only because we used a read-only loopback device, so it was safe, but when trying to mount without the noload option it would refuse to mount because the journey journal replay failed.

Automating the Process

whenever there is a lot of work to do, we always do our best to automate the process to save time and minimize user error. Below you can see the Perl script that we used to inspect the disk.

The script does not have any specific intelligence, it just spits out the result of each test for human inspection; of course it needs to be tuned to the specific environment. All tests are done in a read-only mode and loopback devices were configured before running the script.

When the file system checker for a particular permutation would display 1000s of lines of errors and we would have to kill that process from another window so it would proceed and try the next permutation. In these cases there was so much text displayed on the screen that we would pipe the output through less or directed into a file to inspect it after the run.

This script is for informational use only, use it at your own risk! If you are in need of RAID volume disk recovery on a Linux system than we may be able to help with that. Let us know if we can be of service!

#!/bin/perl

#   This program is free software; you can redistribute it and/or modify
#   it under the terms of the GNU General Public License as published by
#   the Free Software Foundation; either version 2 of the License, or
#   (at your option) any later version.
# 
#   This program is distributed in the hope that it will be useful,
#   but WITHOUT ANY WARRANTY; without even the implied warranty of
#   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#   GNU Library General Public License for more details.
# 
#   You should have received a copy of the GNU General Public License
#   along with this program; if not, write to the Free Software
#   Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
#
#   Copyright (c) 2023 Linux Global, all rights reserved.

use strict;

my @p = (
	[0,1,2,3], [0,1,3,2], [0,2,1,3], [0,2,3,1], [0,3,1,2], [0,3,2,1],
	[1,0,2,3], [1,0,3,2], [1,2,0,3], [1,2,3,0], [1,3,0,2], [1,3,2,0],
	[2,0,1,3], [2,0,3,1], [2,1,0,3], [2,1,3,0], [2,3,0,1], [2,3,1,0],
	[3,0,1,2], [3,0,2,1], [3,1,0,2], [3,1,2,0], [3,2,0,1], [3,2,1,0]);

my $stripe = 256*1024/512;
my $n = 0;

foreach my $p (@p)
{
        next unless $p->[0] =~ /3|1/;

        for my $fmt (qw/raid5_la raid5_ra raid5_ls raid5_rs raid5_zr raid5_nr raid5_nc/)
        {
                activate($p, $fmt);
        }
        $n++
}

sub activate
{
        my ($p, $fmt) = @_;

        system("losetup -d /dev/loop7; dmsetup remove foo");
        print "\n\n========= $n $fmt: @$p\n";

        my $dmsetup = "dmsetup create foo --table '0 23438817282 raid $fmt 1 $stripe "
               . "4 - 7:$p->[0] - 7:$p->[1] - 7:$p->[2] - 7:$p->[3]'";
        
        print "$dmsetup\n";
        system($dmsetup);
        system("gdisk -l /dev/mapper/foo |grep -A1 ^Num");
        system("losetup -r -o 1048576 /dev/loop7 /dev/mapper/foo");
        system("file -s /dev/loop7");
        system("e2fsck -fn /dev/loop7 2>&1");
}