Upgrade Inventory: A Chain of Layers, Not a Version
The PHP version is only one layer; extensions, framework, packages, base image and CI runner each constrain the one above. How to map the chain.
TL;DR
A system is not defined by one version number but by six layers, from the runtime to the CI runner, and each layer's support ends on a different date. The layer whose support ends first sets the upgrade date. An inventory that records the running version, where it is read, the end date and the lock for each layer shows which layer holds the lock before anything breaks.
“We’re on PHP 8.1” is not an inventory, it’s the name of one of six layers.
The sentence sounds like a status report, because a single number seems to answer the question. It doesn’t. That number is the runtime’s version. Around it sit five more layers, each with its own version and its own support calendar: extensions, framework, packages, container image and CI runner. Which upgrade you need is told by the sum of those six, not by the name of one.
In a system managed by a version number, the lock only shows up when something breaks: the build fails, composer update rejects a package, the image doesn’t bring the PHP you expected. The question changes that day from “when do we upgrade” to “why can’t we”. The answer is written in the inventory; if no inventory was drawn up, it gets read for the first time that day.
The lock is most often in a layer you never wrote down: the 7.1 lock in Part 0 was not in the runtime, it was in the package layer.
Six Layers, One Chain
A package sets that lock with a single line: require.php in composer.json leaves the question of which version the runtime can move to in the package author’s hands.
{
"require": {
"php": "^8.1"
}
}
That line limits the PHP version of every project that installs the package. A line in the Dockerfile does the same job one layer below:
FROM php:8.2-fpm-bookworm
FROM pins both the PHP version and the operating system version beneath it. The chain covers everything that sits between these two lines:
- Runtime. PHP itself. The ceiling and the floor of the other five layers pass through here.
- Extensions. Modules compiled into the runtime or loaded later. Their versions depend on the runtime; which ones the project wants is told by the
ext-*entries incomposer.json/composer.lock(or bycomposer check-platform-reqs), whilecomposer show --platformlists only what is installed in the environment where the command runs.
Two layers, two constraints: the runtime decides which extension versions will work, and you can’t move to a PHP that an extension doesn’t support.
- Framework. Laravel 12 requires PHP 8.2–8.5. The framework limits the runtime from above and from below.
- Packages. The
require.phpline above. Every package has its own lower and upper bound; the intersection of all of them is the range your runtime can move within.
The relationship between framework and packages runs both ways too: the framework’s major decides which package versions can be installed, and if one of the packages stays old, it blocks the move to the framework.
- Container image. The
FROMline. Because its version is written down as a tag, it is the easiest layer to read and the easiest to forget; once an image is built, the PHP and operating system inside it age even if your code doesn’t change. - CI runner. The environment that runs the tests and builds the image. It has its own image, its own version, its own retirement date.
Below that sit the operating system kernel and the database server, and both have their own calendars. You can add them to the list as a seventh and eighth row, but I’m staying with six layers here.
What the chain does is this: if one layer can’t move, the neighbors it constrains can’t either. What stops an upgrade is often not the top or the bottom layer, but a link sitting quietly somewhere in the middle.
The Earliest-Ending Layer Sets the Date
Each layer’s clock runs separately and none waits for another. The dates below come from official pages; each belongs to a different layer.
- Runtime. The php.net policy is 2+2: two years of active support, two years of security only. PHP 8.2’s security support ends on 31 December 2026.
- Framework. According to the Laravel 12.x release notes, security fixes for Laravel 12 continue until 24 February 2027. The same page says Laravel 12 requires PHP 8.2–8.5 and Laravel 13 requires PHP 8.3–8.5.
- Container image. Debian 12’s EOL date is 12 July 2026; LTS support runs until 30 June 2028. Debian 13 was released on 9 August 2025.
- CI runner. GitHub announced that the
ubuntu-20.04runner image would be fully retired on 15 April 2025.
The rule that comes out of this list is simple: the upgrade date is set not by the average but by the support that ends earliest. Here that layer is PHP 8.2: 31 December 2026 comes before Laravel 12’s 24 February 2027. Even if the framework’s support continues, the runtime’s support will have ended, and moving to Laravel 13 requires leaving PHP 8.2 behind anyway.
EOL shouldn’t be exaggerated. Upstream support ending doesn’t mean no fix will ever arrive; Debian 12 moving to LTS is an example. But whether a fix arrives now depends on a distribution’s or a vendor’s calendar, and reading what it covers is your job. The inventory puts this distinction in writing: in the end date column, write the upstream date, and next to it the distribution’s extended date if there is one; don’t mix the two in one cell.
The runner row deserves a separate pause. In the other layers a date is a warning; here, once the image is retired, the runs-on value can no longer find that image, no runner is assigned to the job, and it often waits in the queue without even throwing an error. That makes the CI layer the row that comes to mind last but can get expensive first.
The Inventory: Six Rows, Five Columns
The template is this: layer, running version, where it is read, end of support, what it locks. Six layers, six rows; if you add a layer, add a row, not a column. The order of the columns is also a reading order: if you write the version first and then where you read it from, someone else can verify the table from the same sources.
The filled-in version below is for a hypothetical system. I picked only versions whose dates I verified; the package row is deliberately left empty, because that row will be filled from your own lock file.
| Layer | Running version | Where it is read | End of support | What it locks |
|---|---|---|---|---|
| Runtime | PHP 8.2 | FROM line, php -v | security 31 December 2026 | the move to Laravel 13: 13 requires PHP 8.3–8.5 |
| Extension | ext-* entries | composer.lock, composer check-platform-reqs | same as the runtime for core; the package’s repository for PECL | the version the runtime can move to |
| Framework | Laravel 12 | composer.lock | security 24 February 2027 | the runtime to the PHP 8.2–8.5 range |
| Package | from your own lock file | composer.lock, the package’s composer.json | from the package’s repository; “unknown” if there is no date | runtime and framework (the case in Part 0) |
| Container image | Debian 12 based (bookworm) | Dockerfile FROM | EOL 12 July 2026, LTS 30 June 2028 | the runtime version and the operating system packages |
| CI runner | your runs-on value | .github/workflows/*.yml | from GitHub’s runner announcement | the build itself |
The “where it is read” cell of three rows points to the same file, composer.lock. That’s the good news: half of the inventory is already written in files you have. The bad news is that the other half, the image and the runner, is often written down nowhere as a whole, because there is no lock file for it.
“Unknown” is a valid answer. The difference between an empty cell and “unknown” is that the second one gets queued as a job.
The last step of the table is to mark the earliest date in the end of support column. In the example there are two candidates: Debian 12’s 12 July 2026 and PHP 8.2’s 31 December 2026. The first extends to 2028 with LTS for the image’s operating system layer, the second doesn’t extend for the runtime. Which date you take as the basis (EOL or LTS) is a decision; the inventory writes both and doesn’t choose.
The Limit of This Framework
The inventory doesn’t make decisions, it produces input for the decision matrix. Filling in six rows doesn’t mean “upgrade now”; it means making visible what locks what, and by which date.
In which system, with what risk, in what order you’ll proceed is the decision matrix’s job: Part 3 of the series. How to choose the target version is in Part 4, and the prerequisites and testing before the upgrade are in Part 5.
There is one more thing: an end-of-support date is the provider’s calendar, not your risk. The same date goes into a queue for an internal tool and onto the calendar for a payment flow. A date is an upper bound, not urgency. Which signal is mandatory was sorted out in Part 1; this part listed the layers those signals come from.
Back to “We’re on PHP 8.1”
The sentence isn’t wrong, it’s incomplete. It names one of six layers and hides that the lock is most often not in that layer.
The inventory fixes this in two moves: you write down the running version for each layer, then you put the end-of-support dates side by side and mark the earliest. That date is the one that starts the countdown of the upgrade plan; the remaining five rows show what that countdown will collide with.
We’ll use this input in the coming parts: the decision matrix in Part 3, the target version in Part 4, the prerequisites in Part 5.
Pick one of your systems; for each of the six layers, write the running version and the end-of-support date on a single row, and mark the earliest date.