# 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.

- Type: Notes
- Published: 2026-11-01
- Tags: dependencies, decisions, production, devops
- Series: Upgrading in Production
- Reading time: 8 min read
- Source: https://muhammetsafak.com/engineering/notes/upgrade-inventory/
- Language: en-US
- Author: Muhammet Şafak

---
> **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](https://muhammetsafak.com/engineering/essays/the-cost-of-deferring-an-upgrade/) 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.

```json
{
  "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:

```dockerfile
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:

1. **Runtime.** PHP itself. The ceiling and the floor of the other five layers pass through here.
2. **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 in `composer.json`/`composer.lock` (or by `composer check-platform-reqs`), while `composer show --platform` lists 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.

3. **Framework.** Laravel 12 requires PHP 8.2–8.5. The framework limits the runtime from above and from below.
4. **Packages.** The `require.php` line 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.

5. **Container image.** The `FROM` line. 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.
6. **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](https://www.php.net/supported-versions.php) 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](https://laravel.com/docs/12.x/releases), 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](https://wiki.debian.org/DebianReleases) 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.04` runner image [would be fully retired on 15 April 2025](https://github.blog/changelog/2025-01-15-github-actions-ubuntu-20-runner-image-brownout-dates-and-other-breaking-changes/).

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.


> **Tan:** Filling in the inventory once is easy; keeping it current when the FROM line or the runs-on value changes is the hard part. Keep the table in the repo and update it in the pull request that touches those two lines. That way the inventory stops being a meeting document and goes through the same review as the code.


## 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](https://muhammetsafak.com/engineering/notes/upgrade-triggers/); 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.

## Sources

- [Supported Versions](https://www.php.net/supported-versions.php) — PHP
- [Laravel 12.x Release Notes](https://laravel.com/docs/12.x/releases) — Laravel
- [DebianReleases](https://wiki.debian.org/DebianReleases) — Debian
- [GitHub Actions: Ubuntu 20 runner image brownout dates and other breaking changes](https://github.blog/changelog/2025-01-15-github-actions-ubuntu-20-runner-image-brownout-dates-and-other-breaking-changes/) — GitHub
