research/checking-php-versions-against-php-net
Checking Every PHP Install Against php.net in 45 Lines of Code
Engineering Note · October 21, 2026 · 5 minute read · PHP · Security · Maintenance
When a critical PHP release lands, the first question is simple: which of our installs are behind? The answer should take seconds, not an afternoon of logging into servers. This note shows a small Node script we wrote to answer it, run on the day of the October 2026 PHP security release.
Everything below was run on 21 October 2026 with Node.js 22.17.0 against the live php.net and endoflife.date APIs.
The inventory
The script reads a plain text file with one install per line: a name and the PHP version it runs. Keeping this file current is most of the value. The script only compares it to the world.
# name version
brochure 8.5.12
shop 8.4.26
intranet 8.3.36
booking 8.2.30
legacy 8.1.31
archive 7.4.33
These six entries are made up to cover each case. Get the real versions by running php -v on each server or reading the PHP setting in the hosting control panel. Do not trust the X-Powered-By header for this: it can be switched off, and the command-line PHP on a server is not always the one the web server runs.
Where the data comes from
php.net publishes release data as JSON. Asking for a branch returns its newest release and, usefully, the list of branches that still get security fixes:
https://www.php.net/releases/index.php?json&version=8.3
-> "version": "8.3.36", "supported_versions": ["8.2", "8.3", "8.4", "8.5"]
For end-of-life dates we use endoflife.date, which tracks them per branch. We only take dates from it, not version numbers, for a reason covered below.
The script
// Usage: node check-php.mjs inventory.txt
// inventory.txt: one "name version" per line, e.g. "shop 8.3.30". Lines starting with # are ignored.
import { readFileSync } from "node:fs";
const inventory = readFileSync(process.argv[2], "utf8")
.split("\n")
.map((line) => line.trim())
.filter((line) => line && !line.startsWith("#"))
.map((line) => {
const [name, version] = line.split(/\s+/);
return { name, version, branch: version.split(".").slice(0, 2).join(".") };
});
// php.net is the source of truth for the newest patch on each branch. It updates
// the moment a release ships; third-party trackers can lag by hours.
const phpNet = new Map();
for (const branch of new Set(inventory.map((i) => i.branch))) {
const res = await fetch(`https://www.php.net/releases/index.php?json&version=${branch}`);
phpNet.set(branch, await res.json());
}
const supported = [...phpNet.values()].find((r) => r.supported_versions?.length)?.supported_versions ?? [];
// End-of-life dates come from endoflife.date, which tracks them per branch.
const eolDates = new Map(
(await (await fetch("https://endoflife.date/api/php.json")).json()).map((r) => [r.cycle, r.eol]),
);
const newer = (a, b) => {
const [x, y] = [a, b].map((v) => v.split(".").map(Number));
for (let i = 0; i < 3; i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) > (y[i] ?? 0);
return false;
};
const daysUntil = (date) => Math.round((new Date(date) - Date.now()) / 86_400_000);
for (const { name, version, branch } of inventory) {
const latest = phpNet.get(branch)?.version;
const eol = eolDates.get(branch);
let verdict;
if (!latest) verdict = `UNKNOWN branch ${branch} not found on php.net`;
else if (!supported.includes(branch)) verdict = `UPGRADE ${branch} is end of life (since ${eol ?? "?"}); no project patches`;
else if (newer(latest, version)) verdict = `PATCH ${latest} is out`;
else verdict = `OK current patch`;
const runway = supported.includes(branch) && eol ? ` (${daysUntil(eol)} days of security support left)` : "";
console.log(`${name.padEnd(12)} ${version.padEnd(8)} ${verdict}${runway}`);
}
The result
brochure 8.5.12 OK current patch (1166 days of security support left)
shop 8.4.26 PATCH 8.4.27 is out (801 days of security support left)
intranet 8.3.36 OK current patch (435 days of security support left)
booking 8.2.30 PATCH 8.2.35 is out (70 days of security support left)
legacy 8.1.31 UPGRADE 8.1 is end of life (since 2025-12-31); no project patches
archive 7.4.33 UPGRADE 7.4 is end of life (since 2022-11-28); no project patches
Three labels cover what you need to act on:
- OK means the install has the newest patch on a supported branch.
- PATCH means a newer release exists on the same branch. It is a routine update.
- UPGRADE means the branch is out of support, so there is no newer release to install. Fixing it means moving to a supported branch.
The days-left figure matters as much as the label. The booking install will be OK after one update today, but PHP 8.2’s security support ends on 31 December 2026. That is the line to put in the next quarterly plan.
What we found: trackers lag the source
PHP’s security releases went out at about 14:00 UTC on 21 October. At 23:13 UTC, more than nine hours later, endoflife.date’s API, last generated at 20:32 UTC, still listed 8.5.11, 8.4.26, 8.3.35 and 8.2.34 as the newest releases. A version check built on that data would have marked every unpatched install OK on the one day it mattered most.
That is not a criticism of the tracker. It is a volunteer project, and nine hours is quick for one. It is a reason to take the newest patch number from php.net itself and use trackers only for slower-moving facts like end-of-life dates.
Limitations
- Distribution packages. Debian, Ubuntu, Red Hat and others backport fixes into their own packages and often keep the upstream version number. An install the script flags as PATCH may already be safe. For those, check the package version against your distribution’s security notice.
- The inventory is the hard part. The script cannot find installs you have forgotten. Staging sites, old campaign sites and hosting control panels are the usual gaps.
- It checks versions, not exposure. A PATCH result says an update exists. It does not tell you whether that server is reachable from the internet or which PHP setup it uses.
Run something like this every quarter, and on the day of every PHP security release, and the question “are we behind?” always has a quick, accurate answer.
Related reading
- The October 2026 PHP Emergency: Why Staying Current Always Costs Less
- Reading a Security Advisory in the First Hours
- A Runbook for a One-Person Operation
Frequently asked questions
How do I check whether my PHP version is still supported?
Ask php.net. Its release endpoint, www.php.net/releases/index.php?json&version=8.3, returns the newest release for that branch and a supported_versions list of every branch still getting security fixes. If your branch is not on that list, the PHP project no longer ships fixes for it. The supported versions page on php.net shows the same information with end dates.
Why use php.net instead of a version-tracking site?
Timing. On 21 October 2026 PHP released 8.5.12, 8.4.27, 8.3.36 and 8.2.35 at about 14:00 UTC. At 23:13 UTC, endoflife.date's API still listed 8.5.11, 8.4.26, 8.3.35 and 8.2.34 as the latest releases. Trackers are useful for end-of-life dates, but during an emergency the newest patch number should come straight from the source.
Does this script work with Ubuntu or Debian PHP packages?
Only as a first pass. Distributions backport security fixes into their own packages without changing the upstream version number, so a server reporting an older 8.x patch may already be fixed. For distribution packages, check the package version against your distribution's security notice instead of comparing to php.net.
How do I find which PHP version a website runs?
Run php -v on the server, or check the PHP version setting in your hosting control panel. Do not rely on the X-Powered-By response header: it is easy to switch off with expose_php, and the command-line PHP on a server is not always the same version the web server uses.