Skip to content

Raising the PHP Floor

Ed Mozley edited this page Jul 21, 2026 · 6 revisions

Blue sky: what raising the PHP floor to 8.1 would buy us

Status: blue-sky / discussion Β· Nothing here is committed to. This page argues a case, teaches the language features it depends on, and prices the cost honestly. It is not a plan of record.

The decision actually taken (July 2026)

We are staying on the PHP 7.4 floor, and building RBAC phase 3 on class constants instead of enums β€” deliberately shaped so that swapping to enums later is a mechanical find-and-replace rather than a redesign. See Β§5.

This page still argues the case for 8.1, because the case is real and the decision is worth revisiting. But read Β§5 first if you want to know what we're doing now, and why the safety argument turned out to be weaker than the elegance argument.


This page is the decision. The detail lives next door.

Everything below is the case for moving, the honest cost of moving, and what we actually decided.

1. Where we are today, and why 7.4 was the right call once

FreeITSM currently declares PHP 7.4–8.4:

Where What it says
README.md (badge + tech-stack line) PHP 7.4–8.4
Wiki Installation prerequisites "PHP: 7.4 or higher (tested up to 8.4); 8.3/8.4 recommended"
setup/index.php lines 97–104 the only check anywhere in the app

That check is:

// setup/index.php:97-104
$phpVersion = phpversion();
$phpMajorMinor = (float)$phpVersion;
if ($phpMajorMinor >= 7.4) {
    $checks[] = ['name' => t('setup.checks.php_version'), 'status' => 'pass', ...];
} else {
    $checks[] = ['name' => t('setup.checks.php_version'), 'status' => 'fail', ...];
}

Two things are worth noticing about it before we go any further.

First, it does not enforce anything. It renders a red row on the setup checklist. There is no version_compare() and no PHP_VERSION_ID anywhere else in the repo β€” I grepped. The "7.4 floor" is a documented floor, not an enforced one. FreeITSM will happily boot on 7.2 and fail in whatever way it fails.

Second, it's a latent bug. (float)"8.10.0" is 8.1, not 8.10. Casting a version string to a float is fine right up until PHP ships an 8.10 or a 7.10, at which point the comparison silently mis-orders. The idiomatic form is the integer constant PHP itself provides:

if (PHP_VERSION_ID >= 80100) { ... }   // 8.1.0 β†’ 80100, 8.4.3 β†’ 80403

That's worth fixing whatever we decide about the floor.

Why 7.4 was right

7.4 was the sensible floor when FreeITSM started, and the reasoning still holds up: this is self-hosted software aimed at people who already run a LAMP box. Shared hosting, an old Debian, a cPanel account someone's had for six years. The whole pitch is "drop it in a web root and go." A floor you can't meet is a user you never get. 7.4 was the version that was everywhere, and the project deliberately has no Composer dependency tree β€” includes/vendor/firebase-jwt is vendored by hand β€” so nothing external was ever pushing the floor up for us.

But it is an end-of-life floor

To be precise about the dates, because this is where people get vague:

  • PHP 7.4 β€” active support ended 28 November 2021; security support ended 28 November 2022. It has had no upstream security fixes for over three and a half years.
  • PHP 8.0 β€” security support ended 26 November 2023. Also dead.
  • PHP 8.1 β€” security support ended 31 December 2025. (See the honesty note in Β§3: 8.1 is a floor, not a recommendation.)
  • PHP 8.2 β†’ end of 2026, 8.3 β†’ end of 2027, 8.4 β†’ end of 2028, on the published three-year cycle.

Anyone running FreeITSM on 7.4 today is running an unpatched interpreter. That's true whether or not we raise the floor β€” and it's their interpreter, not ours, so it isn't really our security problem. Which brings us to the honest framing:

The 7.4 floor costs us nothing today except expressiveness.

It doesn't make the app slower for anyone (a user on 8.3 already gets 8.3's performance β€” the floor is a minimum, not a target). It doesn't cost us a dependency we can't have (we have no dependencies). It doesn't cost us a security patch. What it costs is that we cannot use any language feature invented after 2019, and one of those features happens to be the exact right shape for a system we shipped last week. That's the whole argument. Everything below is elaboration.


2. What we'd gain

Two pages, so this one stays about the decision:

  • The language features β€” match, enums, constructor promotion, readonly, union types, named arguments, throw as an expression, never, and the small stuff we currently hand-roll. Includes an honest list of the ones that wouldn't help us much (?-> earns us almost nothing in an array-heavy procedural codebase).
  • Enums and the RBAC capability system β€” what drove this whole question. Note that the correctness half of the argument has since been answered without enums (class constants, and deriving the registry from the settings manifest); what remains is one real but narrow gap β€” a guard whose signature could refuse a string outright. That page is honest about the walk-back.

The rest of this page prices it.

3. The honest cost: who actually breaks

The floor is a hard wall, not a warning

This is the part people underestimate. The moment a single .php file in the repo contains 8.1 syntax, PHP 7.4 cannot even parse it. Not "fails at runtime with a nice message" β€” a PHP Parse error on the file, before a single line runs. Which means:

We cannot show a friendly "please upgrade PHP" page from a file that uses the syntax that requires the upgrade.

If we do this, we need a version guard in a 7.4-parseable entry point β€” config.php and/or index.php, using only PHP 5-compatible syntax β€” that checks PHP_VERSION_ID and dies with a clear, human message before require-ing anything that contains 8.1 code:

// config.php β€” must remain parseable by PHP 5.6+. No 8.1 syntax in this file, ever.
if (PHP_VERSION_ID < 80100) {
    header('Content-Type: text/plain; charset=utf-8');
    http_response_code(500);
    exit(
        "FreeITSM requires PHP 8.1 or newer. This server is running " . PHP_VERSION . ".\n\n"
        . "See https://github.com/edmozley/freeitsm/wiki/Upgrading-PHP for how to upgrade.\n"
    );
}

Miss that, and a 7.4 user upgrading gets a white screen and a parse error in a log they may not be able to read. That is a genuinely bad first impression for self-hosted software, and it's the single most important implementation detail on this page.

Who is still on 7.4 or 8.0 in 2026

Being as accurate as I can, and flagging where I'm not certain:

  • Ubuntu 20.04 LTS (focal) ships PHP 7.4 as its distro PHP. Standard support ended April 2025; it's in ESM (paid/ubuntu-pro) until 2030. There are certainly still boxes on it, but it is out of standard support, so anyone still there is already living with an unpatched-by-default stack. This is the single biggest 7.4 population.
  • Debian 11 (bullseye) ships PHP 7.4. Debian 11 LTS runs to roughly mid-2026 β€” i.e. it is about to go out of support, if it hasn't already by the time you read this. Debian 12 (bookworm) ships PHP 8.2, and Debian 13 (trixie) ships 8.4. So the Debian world has already crossed the line.
  • Ubuntu 22.04 LTS (jammy) ships PHP 8.1 β€” which is exactly the floor being proposed, and is not an accident: 8.1 is the widest still-supported LTS-distro default. 24.04 ships 8.3.
  • RHEL / Rocky / Alma: RHEL 8's default AppStream PHP is old (7.2), with module streams for later versions; RHEL 9's default is 8.0, with module streams up to 8.2/8.3. I am not confident of the exact current stream list β€” treat RHEL as "needs a module stream switch or Remi's repo, but the versions are available."
  • Shared / cPanel hosting: cPanel's EasyApache 4 has offered a per-account PHP version selector for years and 8.1–8.3 are standard on any maintained host. The real risk isn't availability, it's the customer who set their account to 7.4 in 2020 and never touched it β€” for them, upgrading is a dropdown, not a migration. That's an important nuance: on shared hosting this is usually trivially fixable.
  • Across essentially all of the above, ondrej/php (Ubuntu/Debian PPA) and Remi's repo (RHEL family) make 8.1–8.4 a package-install away. Nobody is genuinely unable to get 8.1. They're merely unwilling, or unaware.

Verify all of the above before publishing. Distro/EOL facts move, and I'd rather this page be checkably right than confidently wrong.

The 8.1-is-also-EOL problem

The uncomfortable fact, stated plainly: PHP 8.1's own security support ended 31 December 2025. So "raise the floor to 8.1" does not mean "everyone is now on a supported PHP." It means the floor β€” the oldest thing we promise to run on β€” moves to a version that is itself out of upstream support but is still very widely deployed (Ubuntu 22.04 LTS, which is supported until 2027, backports its own security fixes to its 8.1).

That's a defensible position β€” a floor is a compatibility contract, not a recommendation β€” but the README must not blur it. It should say something like:

Requires PHP 8.1 or newer. PHP 8.3 or 8.4 recommended.

Two numbers, two jobs. The floor is the oldest we'll run on; the recommendation is what you should actually install.

What it asks of an existing self-hosted user

Realistically: apt install php8.1 (or flip a dropdown in cPanel), enable pdo, pdo_mysql, curl, openssl, mbstring (the same list setup/index.php:107 already checks), restart Apache, reload the setup page. There is no data migration, no schema change, no config change. The application code doesn't care which of 8.1–8.4 it lands on.

The one non-obvious extension trap: ext/imap was unbundled from PHP core in 8.4 and moved to PECL. FreeITSM's basic IMAP mailbox provider (includes/mailbox_imap.php) needs it, and guards on function_exists('imap_open') β€” so without the PECL build it doesn't crash, it just silently collects no mail. This was a live issue today, independent of this whole discussion, since we already claim support up to 8.4.

Fixed in #827: setup/index.php now reports imap as an optional extension, warning when it's absent and explaining why, and the README carries an "Optional extensions" line. Noting it here because it's the kind of thing a version-floor change surfaces and then gets blamed for.

Docker: a non-event

Dockerfile is already FROM php:8.4-apache. Docker users are already on 8.4 and would notice nothing whatsoever. Same for anyone on a modern distro. The population that feels this at all is: self-hosted users who chose, or inherited, a PHP that has been unpatched since 2022.

And the real cost, which isn't technical

We would be telling some number of people "the thing you installed and are happily running no longer updates for you unless you do sysadmin work." For a free, self-hosted ITSM tool whose entire competitive story is low friction, that is a real cost, and it isn't measured in engineering hours. It's measured in the users who don't upgrade, drift onto an old release, and eventually churn. I don't know how many of those there are, and β€” being honest β€” neither of us has any telemetry that could tell us. That's the actual uncertainty in this decision, and no amount of enum enthusiasm resolves it.


4. Recommendation

This section is the argument taken on its own terms β€” it is not what we decided. Β§5 explains what we're actually doing and where this reasoning turned out to have a hole in it. Read on for the case; read Β§5 for the verdict.

Yes β€” raise the floor to 8.1. Phase it, and don't be precious about the timeline.

The case is straightforward once the arguments are laid out side by side. The 7.4 floor protects users who are running an interpreter that has had no security patches since November 2022, which is a strange thing to be protecting. It costs us the one language feature β€” enums β€” that is precisely the right shape for a permission system we have just built and are about to roll out across sixteen modules. And the failure mode it leaves us exposed to (Β§3.2) is the worst kind: silent, admin-invisible, user-plausible, and permanent.

The clincher, for me, is timing. RBAC phase 3 is the next big roll-out. Every module we wire adds another handful of '<module>.manage' string literals to a system where a typo is a silent, permanent, invisible 403. The cost of not doing this compounds with every module. If the floor is ever going to move, moving it before phase 3 β€” rather than after, when we'd be retrofitting an enum across sixteen modules and ~50 call sites instead of one module and 14 β€” is worth an enormous amount.

The phased path

Phase 0 β€” βœ… done (#826, #827). Cost nothing, committed to nothing.

  • setup/index.php now uses PHP_VERSION_ID, because the float cast was wrong regardless.
  • The setup check is three-state: fail below 7.4 (unchanged), warn on 7.4/8.0 (end of life, no security updates), pass on 8.1+.
  • The ext/imap-on-8.4 check went in at the same time.
  • README keeps the 7.4 floor but now says "PHP 8.3 or 8.4 recommended".

Note that phase 0 deliberately stops short of announcing a removal date. The warn state currently tells the truth ("your PHP is unpatched") without promising anything. If we ever commit to phase 1, the warn text gains "FreeITSM will require PHP 8.1 from version X" and people get two releases' notice β€” and it has to be in the app, not just the release notes, because self-hosted users read release notes at upgrade time, not before. That's what the warn state is for.

Phase 1 β€” the flip.

  • Add the 7.4-parseable version guard in config.php (see "The floor is a hard wall", Β§3). This is the part that must not be skipped, and it must be written and tested first, on an actual 7.4 install, before any 8.1 syntax enters the repo. If we can't test it on 7.4, we shouldn't ship the flip.
  • Raise setup/index.php to fail below 8.1.
  • README badge β†’ PHP 8.1-8.4, floor line β†’ "PHP 8.1 or newer (8.3+ recommended, tested to 8.4)", tech-stack table likewise.
  • Write a short Upgrading PHP wiki page (apt / cPanel dropdown / Remi) and link it from the guard's error message. This page is the entire mitigation for the churn risk in Β§3, so it's worth writing properly.
  • Change no application code in this release. The flip and the first use of 8.1 syntax should be two separate commits, ideally two separate releases, so that if the flip causes screaming we can back it out without unpicking a refactor.

Phase 2 β€” cash it in, RBAC first.

  • includes/Capability.php + Module enum, guards retyped from string to Capability, and the Cap::X call sites renamed to Capability::X.

    ⚠️ This bullet has aged. It was written when the registry was four hand-maintained lists and the swap would have deleted several helper functions. Those lists were collapsed without enums (the registry now derives from each module's settings manifest), so most of what this step promised has already been banked. What an enum would still buy is the typed guard signature β€” see Enums and the RBAC capability system, which walks the claim back honestly. It is also now ~140 call sites rather than fourteen, though they are a mechanical symbol rename the compiler verifies.

  • Then, and only then, start RBAC phase 3 β€” rolling <module>.manage out to the other modules, with each new capability arriving as a typed case that cannot be misspelled, cannot be half-registered, and cannot silently 403 anybody.

  • Everything else on the language-features page β€” AiProvider, MailboxProvider, MessagingProviderKind, LinkRelation, readonly, never β€” is opportunistic. Do it when you're already in the file for another reason. None of it justifies a dedicated refactor, and pretending otherwise is how a language upgrade turns into a six-week yak-shave.


5. The decision, and the bridge

The recommendation above is what the argument supports on its own terms. It is not what we decided, and the honest reason is worth writing down, because it exposes a flaw in the case as I first made it.

What changed my mind: the safety argument is weaker than it looks

The enums-and-RBAC argument rests on one claim: a bare string capability key means a typo becomes a silent, permanent, admin-invisible 403. That claim is true, and it is the strongest thing on this page. But it quietly assumes the only alternatives are bare strings or enums. There is a third option, and it is available in PHP 7.4 today:

// includes/capabilities.php β€” PHP 7.4
final class Cap
{
    const ASSETS_VCENTER = 'assets.vcenter';
    const ASSETS_INTUNE  = 'assets.intune';
    // …
}

requireCapabilityJson(Cap::ASSETS_VCENTER);   // typo β†’ PHP Fatal error: Undefined constant
requireCapabilityJson('assets.vcentre');      // typo β†’ silent 403, forever

A class constant fatals on a typo, at the call site, on the first request that touches the line β€” exactly like an enum case does. It fails for the admin too, because the fatal happens before analystHasCapability() gets a chance to short-circuit on is_admin. That is the entire asymmetry described in Β§3.2, and a const closes it just as completely as an enum does.

So the dangerous-failure argument β€” the one I led with, the one that sounded like a correctness emergency β€” is fully answered without leaving 7.4. I should have noticed that before writing three thousand words about it.

What the enum still genuinely buys, once you're honest

Strip out what constants already give us, and the residue is real but it is elegance, not safety:

  • Metadata on the type. $cap->label(), $cap->module(), $cap->isSensitive() instead of three parallel lookup arrays that must agree. This is the biggest one, and it's why the enum version can afford a feature like isSensitive() that the string version probably never would have grown.
  • Capability::cases() as the registry, instead of reflection over class constants β€” which works, but is uglier.
  • Capability::tryFrom() at the DB boundary, instead of an in_array against a derived key list.
  • A real parameter type. requireCapability(Capability $c) cannot be handed a string at all. The const version still has a string parameter, so requireCapabilityJson('anything') remains syntactically legal β€” it just isn't how anyone would write it.

That last point is the one thing constants genuinely cannot do, and it's worth being precise: constants make the right thing easy and the typo loud, but they don't make the wrong thing impossible. An enum does. That's a real gap. It is not, on reflection, a ~200-call-site, break-every-7.4-user gap.

The bridge: build now, swap cheaply later

This is the part that makes the decision safe rather than merely defensible, so it's worth being concrete about.

The whole "cost compounds with every module" fear in Β§4 rests on one assumption: that every new module scatters more string literals across the codebase, and that converting to enums later means hunting all of them down. That is only true if you write string literals. So we don't.

includes/capabilities.php is deliberately built as an enum wearing 7.4 clothing. Every helper is named after the enum method it will one day become, and every call site names a symbol, never a string:

7.4 β€” what we build today 8.1 β€” what it becomes The swap
Cap::ASSETS_VCENTER Capability::AssetsVcenter find-and-replace the symbol
capAll() Capability::cases() one line, inside the helper
capFromKey($s) β†’ ?string Capability::tryFrom($s) β†’ ?Capability one line
capLabel($c) $c->label() mechanical
capModule($c) $c->module() mechanical
capIsSensitive($c) $c->isSensitive() mechanical
capsForModule($m) Capability::forModule($m) mechanical
requireCapability(string $cap) requireCapability(Capability $cap) the signature only

What the migration actually looks like

The ~200 call sites β€” the guards scattered across every module's endpoints, which is where the volume is β€” change like this:

// today (7.4)
requireCapabilityJson(Cap::ASSETS_VCENTER);

// after the swap (8.1)
requireCapabilityJson(Capability::AssetsVcenter);

That is a rename of a symbol, and it is the kind of thing an IDE does correctly across a whole repository in one action, or that sed does in a line. Crucially it is verifiable: if you miss one, or fat-finger one, the constant no longer exists and PHP fatals. You cannot silently half-finish it. Compare that with hunting 'assets.vcenter' through 200 files, where a missed one keeps working and a mistyped one 403s in silence.

The real work is confined to one file β€” includes/capabilities.php β€” where capRegistry()'s parallel arrays collapse onto the enum as methods:

// 7.4: the label lives in a side array, keyed by the constant
Cap::ASSETS_VCENTER => ['module' => 'assets', 'sensitive' => true, 'label' => 'Configure the vCenter connection…'],

// 8.1: the label lives on the type itself
enum Capability: string {
    case AssetsVcenter = 'assets.vcenter';

    public function label(): string {
        return match ($this) {
            Capability::AssetsVcenter => 'Configure the vCenter connection…',
            // …and if you forget an arm, match() THROWS. You cannot half-add a capability.
        };
    }
    public function isSensitive(): bool { … }
    public function module(): Module    { … }
}

The helpers' bodies change; their names and call sites don't. capLabel($cap) can even survive as a one-line shim (return $cap->label();) if you want the swap to touch nothing but capabilities.php on day one, and unwind the shims later at leisure.

So the honest summary

Choosing constants does not defer the enum migration's cost β€” it removes most of it.

The expensive part of retrofitting a type onto a string-keyed system is finding every string. By never writing the string, we've already done that work, permanently, and the compiler enforces it. What's left is a symbol rename the tooling can do, plus one file of real work.

That is the real answer to Β§4's "cost compounds with every module". The cost only compounds if you write the string literals. Don't write the string literals.

(One caveat, so this isn't oversold: constants make the right thing easy and the typo loud, but they don't make the wrong thing impossible. requireCapabilityJson('anything') remains syntactically legal, because the parameter is still typed string β€” it just isn't how anyone would write it. An enum's signature would refuse it outright. That gap is real, and it is the one thing the swap genuinely buys beyond tidiness.)

When to revisit

The floor should move when the elegance argument is joined by something with teeth. Plausible triggers:

  • A second system wants the same treatment. One enum is a nice-to-have; four or five (capabilities, AI providers, mailbox providers, link relations β€” see Β§4) start to add up.
  • Ubuntu 22.04 goes out of standard support (2027). At that point 8.1 stops being "the widest supported LTS default" and the floor argument gets easier, because the population still on 7.4 has had another two years to shrink.
  • We ever adopt a dependency that requires 8.1+. We have no Composer tree today, which is the main reason nothing external is pushing the floor for us. That could change.

Until one of those lands, the honest position is the one phase 0 already takes: keep the 7.4 floor, tell people running it that their interpreter is unpatched, and recommend 8.3/8.4. Not "we support 7.4 and think that's fine" β€” "we still run on 7.4, and you should not be on it."



See also

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally