Guide
Patch PHP on your servers
· Ed Grosvenor
Last updated
On October 20, 2026, we received word that the following day at 4 pm Zurich time, a patch would be released to fix a critical vulnerability in PHP. We did not, at the time have the details of the vulnerability and, as of the time of this writing, we still don't. We did know that it could potentially impact PHP versions back to 5.4. Some of our clients have legacy PHP systems running on EOL PHP and OS versions, so we scrambled to figure out what it would take to patch them.
In most cases, we were able to identify a reliable source for the patch when it drops. In a handful of cases, we were forced to resort to building our own. This was not a decision that we took lightly, and we made it clear to the client that we do not want to own the building of PHP for these projects long-term. We did it becasue it will let us quickly port the patch to their environment so that these apps can continue to serve traffic once the vulnerability is known and out in the wild. The next conversation we'll have with them is about an immediate plan to either retire or upgrade these applications so that both the operating system and PHP are at least within the supported security release cycle.
Below is a guide that I asked my AI agent to write after we finished building and proving the build pipeline for the systems that we were forced temporarily adopt. You can read it yourself, but I'd recommend pointing your coding agent at it instead. And only if it applies to you. If you're running PHP 8.2 or newer on one of the well supported Linux distributions used by pretty much every hosting company in the world, then just install the patch as described in the official docs. If you're running anything older than 8.2, then this guide can help your agent point you to the best option for you to make things safe as quickly as possible.
It might be that the best thing to do right now is simply take your app offline. Whatever you lose by not serving traffic for a while might be less than what you'd lose by having your server executing someone else's malicious code. And given the severity rating of this thing, it's not a matter of if, but when. And when might be very, very soon.
If you're running on Laravel Cloud, they have you covered. They'll patch everything on their end. The most you'll have to do is trigger a deployment. If you have a lot of things running on Cloud, you can use this script to re-deploy everything in one go via the Cloud CLI: https://gist.github.com/edgrosvenor/81a30430c59a2e3b2e808926efdbd6a4
The guide below references a script that we built last night to help us quickly gather all the information we needed to source (or fail to source) the patch we need. You can find that script here: https://github.com/artisan-build/php-inventory
Stay safe out there, everyone. And now, I'm handing the mic over to Claude:
A PHP security advisory lands and the question looks simple: upgrade PHP. On one server it is simple. On a fleet that grew over a decade, it is not, because the version number on a host tells you almost nothing about whether that host can be patched.
Two servers both running 7.4.33 can be in completely different positions. One of them installed PHP from Ondřej Surý's packages on Ubuntu 22.04 and gets the fix with apt upgrade, free, on the day he publishes it. The other installed the same upstream version from the same packager's Ubuntu 16.04 suite, and that suite no longer exists on the mirror. It cannot download the fix. It cannot even re-download the package it is running right now.
What decides the patch path is the repository the package came from. That is where this guide starts.
First, find out where each host's PHP came from
For every host, you need four things: which PHP packages are installed, which repository each one came from, which of them is actually serving traffic, and which Ubuntu release the host is on. Version numbers alone will send you down the wrong path.
You can collect this by hand. dpkg-query gives you the installed packages and their full version strings:
dpkg-query -W -f='${Package} ${Version}\n' 'php*' | sort
The version string is the useful part, because the packagers mark their own builds. A version like 1:7.4.33-30+ubuntu22.04.1+deb.sury.org+1 came from Surý. One like 7.4.3-4ubuntu2.29 came from the Ubuntu archive. One carrying +esm4 came from an Ubuntu Pro channel. A bare upstream version with no Debian revision at all usually means somebody compiled it on the host.
apt-cache policy tells you which configured repository currently wins for a package, which is not always where the installed copy came from:
apt-cache policy php7.4-fpm
apt policy php7.4-fpm # same thing on releases that have the apt wrapper
Then find what is running, rather than what is installed. An FPM pool that is installed and enabled but stopped right now looks like nothing in a process list, and an old FPM still listening on a socket is the one that matters:
systemctl list-unit-files | grep -E 'php|apache2|nginx'
sudo ss -ltnp | grep -E 'php|:9[0-9]{3}'
ps -eo pid,comm,args | grep -E 'php-fpm|apache2' | grep -v grep
On Ubuntu 14.04 there is no systemd, so use ls /etc/init/*php* /etc/init.d/*php* and sudo initctl list | grep php instead.
Check whether the host is attached to an Ubuntu Pro subscription while you are there, because that decides whether a whole patch path is open to you:
sudo pro status --all
sudo ua status --all # older releases ship the ua client instead
Doing all of that by hand across twenty hosts is where the mistakes come from. We wrote a script that collects it in one pass, at artisan-build/php-inventory. It only reads: no installs, no configuration changes, one file written under /tmp. Copy it onto the host and run it:
sudo sh php-inventory.sh
It works without sudo with a few sections thinner, and it names every section it had to skip and why. It strips credentials out of apt source lines before printing, so an attached Pro token does not end up in the report. summarize.py reads the collected reports and sorts every PHP package it found into a patch path, with its reasoning printed per package.
Run it on the hosts you believe have no PHP too. A report that says no php binary found is how that belief stops being an assumption. The script's Docker section is also how you find PHP hiding inside a container, which no host-level package list will show you.
Which branches still get fixes from php.net
PHP supports a branch for two years of active support and then two more of security fixes. As of October 2026 that leaves four branches alive: 8.2 (security fixes until 31 Dec 2026), 8.3 (31 Dec 2027), 8.4 (31 Dec 2028) and 8.5 (31 Dec 2029). Everything from 8.1 down is end of life upstream. 8.1 reached end of life on 31 Dec 2025, 8.0 on 26 Nov 2023, 7.4 on 28 Nov 2022.
End of life upstream does not mean unpatchable. It means php.net is not the source any more.
Part one: supported OS, supported PHP
This is the easy case, and it is worth being systematic about it anyway, because the verification steps are the same ones you will want on the hard hosts.
Snapshot first
Take the snapshot before you change anything. On EC2:
aws ec2 create-image --instance-id i-0123456789abcdef0 \
--name "pre-php-patch-$(date +%F)-web1" --no-reboot
--no-reboot keeps the host up and gives you a crash-consistent image, which is usually the right trade. If the host can take a short outage, drop the flag and get a clean one.
Record the current package versions too, because after the upgrade "did it move" is the only question that matters, and it needs a before:
dpkg-query -W -f='${Package} ${Version}\n' 'php*' | sort > ~/php-versions-before.txt
Stock Ubuntu packages
The PHP that ships with Ubuntu is php5 on 14.04 and phpX.Y from 16.04 on: php7.0 on 16.04, php7.2 on 18.04, php7.4 on 20.04, php8.1 on 22.04, php8.3 on 24.04, php8.5 on 26.04. One version per release, patched in place for the life of the release. 22.04's PHP will be 8.1.2 until 22.04 dies.
A release inside its five years of standard support gets those fixes free. Right now that is 22.04 (standard support to May 2027), 24.04 (May 2029) and 26.04 (May 2031). Nothing to configure:
sudo apt update
apt list --upgradable | grep -i php
sudo apt install --only-upgrade 'php*'
Quote the 'php*' so the shell does not try to expand it against the current directory.
To have this happen without you, unattended-upgrades is installed by default on Ubuntu Server. Its default Unattended-Upgrade::Allowed-Origins in /etc/apt/apt.conf.d/50unattended-upgrades already covers the security pockets and both ESM streams:
Unattended-Upgrade::Allowed-Origins {
"${distro_id}:${distro_codename}";
"${distro_id}:${distro_codename}-security";
"${distro_id}ESMApps:${distro_codename}-apps-security";
"${distro_id}ESM:${distro_codename}-infra-security";
};
Turn the runs on in /etc/apt/apt.conf.d/20auto-upgrades:
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
Then check what it would actually do, which is the step people skip:
sudo unattended-upgrade -v --dry-run
A third-party repository is not covered by that default, and adding the repository does not change it. You have to name its origin. For Surý's packages that is deb.sury.org on packages.sury.org and LP-PPA-ondrej-php on the Launchpad PPA, which you can read out of the repository's own Release file or out of /var/lib/apt/lists/ after an apt update. A host on the PPA wants a line like "LP-PPA-ondrej-php:${distro_codename}"; added to Allowed-Origins, and then the dry run again to prove it took.
Ondřej Surý's packages
Most Laravel servers do not run the distro's PHP. They run Surý's, because that is how you get a current PHP branch on an older Ubuntu, and how you get several branches side by side.
His packages are moving from the Launchpad PPA to packages.sury.org. The PPA's own page now says it "is in process of being merged into https://packages.sury.org/php/", that packages.sury.org is the canonical source for Ubuntu 26.04, and that you can still use the PPA for 22.04 and 24.04. The repository's setup script is:
sudo apt-get update
sudo apt-get -y install lsb-release ca-certificates curl
sudo curl -sSLo /tmp/debsuryorg-archive-keyring.deb \
https://packages.sury.org/debsuryorg-archive-keyring.deb
sudo dpkg -i /tmp/debsuryorg-archive-keyring.deb
sudo sh -c 'echo "deb [signed-by=/usr/share/keyrings/debsuryorg-archive-keyring.gpg] https://packages.sury.org/php/ $(lsb_release -sc) main" > /etc/apt/sources.list.d/php.list'
sudo apt-get update
On a release he builds for, patching is the same apt upgrade as stock Ubuntu. The difference is in how you confirm it landed. For an end-of-life branch the fix is a backport, so the upstream part of the version does not change. 7.4.33 stays 7.4.33 forever. What moves is the Debian revision after it, -30 to -31, and the datestamped suffix on packages.sury.org builds:
dpkg-query -W -f='${Package} ${Version}\n' 'php*' | sort
If that revision is the same number as the one in your before-file, the new package is not on the mirror yet and your apt install --only-upgrade exited 0 having done nothing. Exit status is not evidence here. The version string is.
Restart the thing that is actually serving
An apt upgrade replaces the binary on disk. The FPM master that is serving requests keeps running the copy it started with, and on some upgrade paths nothing restarts it for you.
php8.1 -v
sudo php-fpm8.1 -t
sudo systemctl restart php8.1-fpm && systemctl is-active php8.1-fpm
Substitute the version. On 14.04, with no systemd and the stock php5 packages:
php5 -v
sudo php5-fpm -t
sudo service php5-fpm restart && sudo service php5-fpm status
If a host serves PHP through Apache's mod_php rather than FPM, restarting Apache is what reloads PHP: sudo systemctl restart apache2, or sudo service apache2 restart on 14.04.
Then prove the running master really is the new binary, instead of assuming the restart worked. A process still holding a replaced file shows it. Ask systemd for the master's PID rather than looking for a pidfile, because Debian's FPM unit runs --nodaemonize under Type=notify and declares no PIDFile:
sudo ls -l /proc/$(systemctl show -p MainPID --value php8.1-fpm)/exe
A path ending in (deleted) means that master is running the binary you just upgraded away from. On 14.04, with no systemd, find it with pgrep -f 'php-fpm: master' instead. Ubuntu packages this check as needrestart, which scans every process rather than the one you remembered:
sudo needrestart -b
It is in main on 22.04 and 24.04, and in universe on 20.04.
Do not forget everything else that loaded PHP into memory. Queue workers, Horizon, a scheduler's long-running processes and any supervised daemon are all holding the old binary until they are restarted. On a Laravel host that is at least:
php artisan queue:restart
sudo supervisorctl restart all
OPcache is a separate question and usually a non-issue: it caches compiled scripts, not the PHP binary, and a fresh FPM master starts with a cold cache. If you run OPcache with opcache.validate_timestamps=0, your deploy already has to clear it, and a PHP upgrade is no different.
Then check the thing a user would check, from outside the host, and read the log instead of assuming:
curl -sS -o /dev/null -w '%{http_code}\n' https://example.com/
sudo tail -n 50 /var/log/php*-fpm.log
To read what actually changed in the package, the installed changelog is the dependable copy, because apt changelog downloads from a changelog server that third-party repositories generally do not run:
zcat /usr/share/doc/php8.1-fpm/changelog.Debian.gz | head -40
apt changelog php8.1 # works for Ubuntu archive packages
Rolling back
Rollback is an install of the old version, which means you need the old version's .deb files. Collect them while they are still the installed ones, as part of the snapshot step. Ubuntu's archive keeps only the newest version in -updates and -security, so the moment the fix is published the package you were running stops being downloadable, and apt install php8.1-fpm=8.1.2-1ubuntu2.26 fails unless that file happens to still be sitting in /var/cache/apt/archives.
So add this to the snapshot step, while the installed versions are still the current ones:
mkdir -p ~/php-rollback && cd ~/php-rollback
dpkg-query -W -f='${Package}=${Version}\n' 'php*' | sort > installed.txt
apt-get download $(cat installed.txt)
ls -1 *.deb > rollback-manifest.txt
Pass the whole package=version line, not just the package name. apt-get download php8.1-fpm
fetches the candidate version, which after the publisher ships is the fix you are trying to keep
a way back from.
If apt-get download cannot find a package, because its repository has already moved on or no longer exists, repack the copy on the host, which is the only one left:
sudo apt-get install -y dpkg-repack
cut -d= -f1 installed.txt | xargs -n1 sudo dpkg-repack
Then rolling back is a local install with a hold, so the next apt upgrade does not undo it:
cd ~/php-rollback
sudo apt install --allow-downgrades ./*.deb
sudo apt-mark hold 'php8.1*'
sudo systemctl restart php8.1-fpm
Every path needs its own ./, which is why this globs the directory rather than expanding the
manifest: apt reads an argument without a slash in it as a package name, so one ./ in front of
a list silently turns every file after the first into a name lookup. The manifest is there to be
read by a human checking what was saved, and to be diffed against ls if a file goes missing.
That form needs apt 1.1 or newer. Ubuntu 14.04 ships apt 1.0.1, which cannot install a local file at
all: it splits the argument on the slash, reads what follows as a release name, and exits 100 with
E: Release '...' for '...' was not found having installed nothing. 16.04 is already on apt 1.2, so
the lines above are right for everything from there up. On 14.04, install with dpkg and let apt
settle the dependencies afterwards. Substitute your version: there is no systemd on 14.04, and no
php8.1 either, so a host like this is more likely running php7.0 from Surý's trusty suite.
cd ~/php-rollback
sudo dpkg -i ./*.deb
sudo apt-get -f install
sudo apt-mark hold 'php7.0*'
sudo service php7.0-fpm restart
Read what apt-get -f install proposes before you accept it. On a host whose repository no longer
exists it can offer to remove packages rather than fetch what is missing, which on an orphaned host
is the opposite of what you want.
Take the hold off once you have a working forward path, and write down somewhere that you set it. A held PHP package is a host that stops getting security updates while still appearing to be managed.
The instance snapshot is the backstop behind all of this. If the packages are gone and the repack did not work, that image is what you have.
Part two: when the OS or the PHP branch is end of life
Now the hard hosts. There are two separate end-of-life questions and they have different answers, so take them one at a time: is the PHP branch dead upstream, and is the Ubuntu release past free support?
Supported OS, end-of-life PHP branch
A host on Ubuntu 22.04 or 24.04 running PHP 7.4, 8.0 or 8.1 is in better shape than it looks.
The real fix is to upgrade PHP. An end-of-life branch means you are relying on a backporter's judgement about which upstream fixes matter and their schedule for shipping them, on top of the application bugs nobody is fixing. Everything below is how you survive until you can do that, and none of it is as good as doing it.
While you are surviving: Surý builds end-of-life branches for the Ubuntu releases he supports. On 22.04, 24.04 and 26.04 today, packages.sury.org publishes php5.6 through php8.6 — 5.6.40, 7.0.33, 7.1.33, 7.2.34, 7.3.33, 7.4.33, 8.0.30, 8.1.34 and the live branches alongside them. For a host on one of those releases, an eleven-year-old PHP branch still patches with apt.
The fixes themselves come from Remi Collet, who maintains security backports as branches on remicollet/php-src-security: PHP-5.6-security-backports through PHP-8.1-security-backports, with 7.2 the odd one out at PHP-7.2-Security-backports. His README names both deb.sury.org and Freexian's PHP LTS as providers building from his branches, so his pace sets theirs. That matters for expectations. Those branches do not all move together. In October 2026 his 7.4 and 8.0 branches had commits from 2026-10-16, 8.1 from 2026-10-01, 7.2 and 7.3 from 2026-08-07, and 5.6, 7.0 and 7.1 from 2026-07-02.
There is no 5.5 branch, and nobody else in the community backports to 5.5 either. For a stock php5 install on Ubuntu 14.04, Canonical is the only source that exists.
If a host is on a release Surý no longer builds for, the commercial option is Freexian's PHP LTS, which covers Ubuntu 18.04 through 26.04 and Debian 9 through 13, PHP 5.6 through 8.5, and is operated with Ondřej Surý's help. The Basic tier is listed at 1,200 € per year for 1 to 30 systems and up to 4 platforms, on a price sheet dated 2025-11-01. Ask them what counts as a platform before you buy, because a fleet spread across four PHP branches and two Ubuntu releases may not be the one platform it looks like.
End-of-life OS
An Ubuntu release gets five years of free security maintenance. Ubuntu Pro extends that to ten, and the Legacy add-on to fifteen. The dates matter more than the shape, so here they are:
| Release | Free support ends | Ubuntu Pro ends | Legacy add-on ends |
|---|---|---|---|
| 14.04 | Apr 2019 | May 2024 | May 2029 |
| 16.04 | Apr 2021 | May 2026 | May 2031 |
| 18.04 | May 2023 | May 2028 | May 2033 |
| 20.04 | May 2025 | May 2030 | May 2035 |
| 22.04 | May 2027 | May 2032 | May 2037 |
| 24.04 | May 2029 | May 2034 | May 2039 |
| 26.04 | May 2031 | May 2036 | May 2041 |
Read your host's release off that table and you know which channel its stock packages come from. In October 2026: 22.04 and newer are free, 18.04 and 20.04 need Ubuntu Pro, and 14.04 and 16.04 have run past even that and need the Legacy add-on.
Pro has two ESM streams and the difference decides whether PHP is covered. esm-infra covers the main repository after the standard five years; esm-apps covers universe for ten years from release. The phpX.Y source package is in main on every Ubuntu LTS from 14.04 to 26.04, so core PHP is esm-infra work. PECL extensions packaged separately are not: php-redis and php-imagick are both in universe, so they are esm-apps. Enable both and stop thinking about it.
Attaching is interactive, and the free personal tier is the default option on the attach page:
sudo pro attach
sudo pro enable esm-infra
sudo pro enable esm-apps
sudo apt update
sudo apt upgrade
Canonical's terms are that Ubuntu Pro "is and always will be free for personal use on up to 5 physical machines, or 50 machines for official Ubuntu Community members". The page says nothing about commercial use at that tier, so if your fleet is a company's, price it with Canonical rather than reading the free tier as covering you.
On 14.04 the Pro client is the older ua, Legacy support is a separate service, and the client has to be new enough to know about it:
sudo apt update && sudo apt install ubuntu-advantage-tools
sudo ua status
sudo ua enable esm-infra-legacy
If esm-infra-legacy comes back as an unknown service, that client is older than the service. Upgrade ubuntu-advantage-tools and try again. Canonical documents esm-infra-legacy as a Trusty-only service name, so on 16.04 read sudo pro status --all to see which service carries Legacy on that host, and enable that one.
Then upgrade by name, because the stock package names differ by release:
dpkg-query -W -f='${Package}\n' | grep -E '^php5?[0-9.]*(-|$)' > /tmp/php-pkgs.txt
sudo apt install --only-upgrade $(tr '\n' ' ' < /tmp/php-pkgs.txt)
The +esm marker in the resulting version is how you confirm the host is pulling from an ESM channel rather than having silently stopped receiving updates. Canonical's PHP update of 2026-10-19, USN-8936-1, shows every channel at once: 8.1.2-1ubuntu2.27 on 22.04 and 8.3.6-0ubuntu0.24.04.12 on 24.04, both free; 7.4.3-4ubuntu2.29+esm4 on 20.04 and 7.2.24-0ubuntu0.18.04.17+esm13 on 18.04 behind Ubuntu Pro; 7.0.33-0ubuntu0.16.04.16+esm21 on 16.04 and 5.5.9+dfsg-1ubuntu4.29+esm17 on 14.04 behind the Legacy add-on.
On Debian the equivalents are the LTS project, which extends a stable release to at least five years and is run by volunteers and companies rather than the Debian security team, and Extended LTS, which is Freexian's commercial service and takes a release to ten.
Why old Surý packages are a dead end
ESM only covers packages that came from Ubuntu. If a host's PHP came from Surý's suite for an Ubuntu release he has since dropped, an Ubuntu Pro subscription does nothing for it, and neither does the PPA.
packages.sury.org does not serve the dropped suites at all. dists/trusty/Release, dists/xenial/Release, dists/bionic/Release and dists/focal/Release all return 404, while dists/jammy/Release, dists/noble/Release and dists/resolute/Release are fine. That has a consequence worth planning for before you need it: a host on one of those suites cannot apt-get download the packages it is currently running, so the usual "keep the old .deb for rollback" step does not work. The only copy of that package left anywhere is the installed one. Repack it:
mkdir -p ~/php-rollback && cd ~/php-rollback
dpkg -l | awk '/^ii php7\.4/ {print $2}' > installed.txt
sudo apt-get install -y dpkg-repack
xargs -n1 sudo dpkg-repack < installed.txt
ls -1 *.deb > rollback-manifest.txt
Repack on an orphaned host before you touch anything else on it.
The honest interim
Some hosts will not have a patch available on day one, and what is actually useful depends on what the advisory says. Read it first. It names the affected component and the affected versions, and that tells you three things: whether the host is affected at all, whether the exposed code path is reachable through the SAPI that host runs, and whether a mitigation exists in the meantime. A flaw in an extension a host does not load is not urgent on that host.
Cut off the network path. Tighten the instance's security group to the sources that have to reach it, or move it behind a reverse proxy so nothing reaches PHP directly. This is the mitigation that does not depend on what the advisory says, and on a host years past its support date it is worth doing on its own merits. PHP is one item on a long list of unpatched packages there.
Turn off the affected code once the advisory names it. If the flaw is in an extension the host does not need, disable it for the SAPI that loads it and restart:
sudo phpdismod -v 7.4 -s fpm imap
sudo systemctl restart php7.4-fpm
phpdismod and phpenmod come from php-common and take -v for the PHP version and -s for the SAPI, both accepting ALL. On 14.04's php5 packages the command is sudo php5dismod imap. If the flaw is in a single function rather than an extension, disable_functions in php.ini covers it. Test either against staging first, because both can break an application in ways that look nothing like a PHP error.
Stop what is not in use. An installed-but-idle FPM for an old branch should be stopped and disabled rather than patched, and that is a smaller job than either.
Building your own, as a last resort
If a host's PHP came from a repository that no longer exists, and nobody sells a subscription that covers its release, and the host cannot be upgraded or retired on the timescale you have, you can rebuild the package yourself.
Be clear about what you are taking on. From the moment you install your own PHP on a host, you own that PHP's security. Every future advisory becomes your build, your test and your deploy, and there is nobody to escalate to. The cost is not the first build. It is the next one, and the one after that, indefinitely, including the ones that land while you are on holiday. A migration you can finance is usually cheaper than a build farm you have to staff.
If you are still here, the method is to take a source package somebody else maintains and move it to an older release, rather than configuring and compiling PHP from scratch. You inherit a working package layout, the extension set, the systemd units and the /etc/php/<X.Y>/ conf.d structure, which is what makes the result an in-place upgrade on the host rather than a parallel install the application cannot see.
The shape of it:
Take Surý's current source package for the branch, built for the nearest release he does support. The PPA pool is a plain directory listing, so the newest .dsc is findable without any tooling:
curl -s https://ppa.launchpadcontent.net/ondrej/php/ubuntu/pool/main/p/php7.4/ \
| grep -oE 'php7\.4_[^"]*\+ubuntu22\.04\.1\+deb\.sury\.org\+1\.dsc' | sort -u
Download the .dsc, the .debian.tar.xz and the .orig.tar.xz next to each other. The orig tarball is the plain upstream release; every fix since then is a numbered quilt patch in debian/patches/, listed in debian/patches/series. On the 7.4 package that series runs to well over a hundred patches. The security ones are git format-patch files whose headers carry the cherry-pick chain, so you can read which upstream commit a fix came from, and most of them carry the advisory's own .phpt test.
Get the fix in as one more patch on the end of that series. If Remi Collet has already pushed the backport, take it off his branch:
git clone --filter=blob:none https://github.com/remicollet/php-src-security.git
git -C php-src-security format-patch --no-signature -o /path/to/overlay/php7.4 \
--start-number 9000 <last-commit-sury-shipped>..origin/PHP-7.4-security-backports
Numbering from 9000 keeps your patches sorting after the maintainer's, which is what you want: theirs applies first, yours last. If Remi has not pushed yet, the advisory names the upstream fix commits and you take them from php-src the same way, which is more work because they will not apply cleanly to a branch that diverged years ago.
Rebuild in a container of the target release, not on your workstation. The point of the container is that the build links against the libraries the host actually has. The image holds nothing but the target release and Debian packaging tools, because the package's own Build-Depends supplies everything PHP-specific:
ARG RELEASE=xenial
FROM ubuntu:${RELEASE}
ENV DEBIAN_FRONTEND=noninteractive LANG=C.UTF-8
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential devscripts equivs quilt dpkg-dev debhelper fakeroot \
ca-certificates curl xz-utils patch lsb-release pkg-config \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /work
archive.ubuntu.com still carries 14.04 and 16.04 today, so that apt-get update works in a plain ubuntu:trusty or ubuntu:xenial container. Do not assume it always will: retired releases move to old-releases.ubuntu.com, which is where every dead interim release already lives. If the build image's apt-get update starts 404ing, rewriting sources.list to point there is the fix, and it is worth checking before a release-day build rather than during one.
Then:
docker build --platform linux/amd64 --build-arg RELEASE=xenial -t phpbuild:xenial .
docker run --rm --platform linux/amd64 -v "$PWD:/work" phpbuild:xenial bash -c '
dpkg-source --no-check --skip-patches -x /work/src/php7.4_*.dsc /work/build/php7.4
cd /work/build/php7.4
# drop in your patches, append them to debian/patches/series
dpkg-checkbuilddeps
DEB_BUILD_OPTIONS="nocheck parallel=4" dpkg-buildpackage -us -uc -B -j4'
Expect the older release to force edits to the source package, and log every one of them. A library the newer release has may not exist on the older one, in which case a configure flag has to go and a feature goes with it. Those edits are the difference between your package and the maintainer's, and the log of them is the answer to "what did we change" six months from now when something behaves oddly.
Give the result a version that sorts above what the host has and says where it came from:
dch --force-distribution -D xenial \
-v '1:7.4.33-30+ubuntu22.04.1+deb.sury.org+1+local1~ubuntu16.04.1' \
'Rebuild of 7.4.33-30 for xenial. See the build log for source edits.'
Then check the sort rather than eyeballing it, because Debian version comparison has epochs and tilde ordering and is not intuitive:
dpkg --compare-versions \
'1:7.4.33-30+ubuntu22.04.1+deb.sury.org+1+local1~ubuntu16.04.1' gt \
'7.4.3-4+ubuntu16.04.1+deb.sury.org+1' && echo sorts-above
Smoke-test in a fresh container of the target release before any host sees it. Install the .deb files with apt so dependencies resolve, then check the version, the loaded extension list against the one the host has today, the FPM config, and a real request through FPM over its socket:
apt-get install -y --no-install-recommends /debs/php-common_*.deb /debs/php7.4-*.deb
php7.4 -v
php7.4 -m | sort # diff this against the host's current php -m
php-fpm7.4 -t
php-fpm7.4 --fpm-config /etc/php/7.4/fpm/php-fpm.conf --daemonize
SCRIPT_FILENAME=/var/www/html/probe.php SCRIPT_NAME=/probe.php REQUEST_METHOD=GET \
cgi-fcgi -bind -connect /run/php/probe.sock
On a 14.04 container that first line fails with E: Unable to locate package /debs, for the same
reason the rollback section gives: apt 1.0.1 reads the path as a package name. Install with dpkg
there instead:
dpkg -i /debs/php-common_*.deb /debs/php7.4-*.deb
apt-get -f install -y
The extension diff is the check that catches the real problems. A missing extension is how a dropped configure flag reaches production, and php -v will not tell you. Run the advisory's own .phpt against the built CLI too: a fix whose test does not pass is not a fix.
NO_INTERACTION=1 TEST_PHP_EXECUTABLE=$(command -v php7.4) \
php7.4 run-tests.php -q ext/openssl/tests/<test-from-the-advisory>.phpt
Install on one canary host, with the rollback .deb files already repacked and a manifest written down. Install with apt install ./*.deb rather than dpkg -i, so dependencies resolve from the host's own sources:
sudo apt install ./php7.4-*.deb ./libphp7.4-*.deb
php7.4 -v && sudo php-fpm7.4 -t
sudo systemctl restart php7.4-fpm && systemctl is-active php7.4-fpm
curl -sS -o /dev/null -w '%{http_code}\n' https://example.com/
# rollback
cd ~/php-rollback && sudo apt install --allow-downgrades ./*.deb
sudo systemctl restart php7.4-fpm
On a 14.04 canary, both apt install lines above fail the same way, so install with dpkg and
resolve afterwards:
sudo dpkg -i ./php7.4-*.deb ./libphp7.4-*.deb
sudo apt-get -f install
php7.4 -v && sudo php-fpm7.4 -t
sudo service php7.4-fpm restart && sudo service php7.4-fpm status
# rollback
cd ~/php-rollback
sudo dpkg -i ./*.deb
sudo apt-get -f install
sudo service php7.4-fpm restart
Then read the application's error log for a few hours before you touch the second host.
One class of problem to look for before you start: a feature the newer release's libraries provide and the older one's do not. Argon2 password hashing is the example that bites Laravel applications, because libargon2-dev only arrives in Ubuntu 18.04. Build 7.4 for 16.04 or 14.04 and PASSWORD_ARGON2I and PASSWORD_ARGON2ID are gone, while bcrypt is unaffected. If the application hashes with Argon2 and the host's current PHP supports it, your rebuild will break every login. Check both sides before you build:
php7.4 -i | grep -i argon
grep -rn "PASSWORD_ARGON2\|'argon'" app/ config/
Decision table
| Situation | Where the patch comes from | Cost and risk |
|---|---|---|
| Supported Ubuntu, stock PHP | Ubuntu archive, apt |
Free. Verify the version moved and restart FPM. |
| Supported Ubuntu, live PHP branch from Surý | packages.sury.org or the PPA, apt |
Free. Same verification, plus adding the origin to unattended-upgrades. |
| Supported Ubuntu, dead PHP branch from Surý | Surý's end-of-life builds, apt |
Free, and dependent on one person's schedule. Upgrade the branch when you can. |
| Ubuntu past free support, stock PHP, within ten years | Ubuntu Pro esm-infra + esm-apps |
Paid subscription. apt from then on. The +esm marker proves it. |
| Ubuntu past ten years, stock PHP | Legacy add-on on top of Pro | Paid, priced above standard Pro. The only source for 14.04's php5. |
| Ubuntu 18.04–26.04, PHP from a dropped Surý suite | Freexian PHP LTS | 1,200 € per year at the Basic tier. Confirm what counts as a platform. |
| Ubuntu 14.04 or 16.04, PHP from a dropped Surý suite | Your own build, or retire the host | No money, significant ongoing time. You own that PHP's security from then on. |
| PHP 5.5 anywhere | Canonical only, with Legacy | No community backport branch exists. If the host is not stock Ubuntu 14.04, there is no patch path. |
| Compiled from source on the host | You, already | Rebuild from upstream. Nothing is going to patch this for you. |
| PHP inside a container | The base image's publisher | Rebuild and redeploy the image. A host-level package list will not show it. |
Where to start tomorrow
Run the inventory everywhere, including the hosts you are sure have no PHP, and including the ones nobody has credentials for. An unsurveyed host is the one you cannot plan for at all, and finding out who can get onto it is slower than it sounds.
Then sort what comes back by patch path rather than by version number, and the fleet turns into three or four piles. The pile that needs an apt upgrade is usually the biggest and is done in an afternoon. The pile that needs a purchase decision needs someone with a budget, so start that conversation before the advisory, not after. The pile that nobody packages for is the one to be honest about: upgrading or retiring those hosts is almost always cheaper than owning their PHP forever.
Changelog
- Published.
- Corrected the package install commands for Ubuntu 14.04.