-
Notifications
You must be signed in to change notification settings - Fork 15
Raising the PHP Floor
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.
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.
- PHP 8: the language features we can't use β the 101 tour of 8.0/8.1, with before/after from real FreeITSM code, and what else in the codebase would visibly improve.
- PHP enums and the RBAC capability system β the one feature the argument turns on, in depth. Read it if you want to know why a capability is a
Cap::constant and never a string.Everything below is the case for moving, the honest cost of moving, and what we actually decided.
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 β 80403That's worth fixing whatever we decide about the floor.
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.
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.
Two pages, so this one stays about the decision:
-
The language features β
match, enums, constructor promotion,readonly, union types, named arguments,throwas 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.
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.
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 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.
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.
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.
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.
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.
Phase 0 β β done (#826, #827). Cost nothing, committed to nothing.
-
setup/index.phpnow usesPHP_VERSION_ID, because the float cast was wrong regardless. - The setup check is three-state:
failbelow 7.4 (unchanged),warnon 7.4/8.0 (end of life, no security updates),passon 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.phptofailbelow 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+Moduleenum, guards retyped fromstringtoCapability, and theCap::Xcall sites renamed toCapability::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>.manageout to the other modules, with each new capability arriving as a typedcasethat 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.
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.
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, foreverA 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.
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 likeisSensitive()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 anin_arrayagainst a derived key list. -
A real parameter type.
requireCapability(Capability $c)cannot be handed a string at all. Theconstversion still has astringparameter, sorequireCapabilityJson('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.
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 |
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.
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.)
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."
- PHP 8: the language features we can't use β the 101 tour
- PHP enums and the RBAC capability system β the argument in depth
- Roles β Developer Guide β how capabilities are written on 7.4, and the bridge in practice
- Blue sky thinking β the other parked ideas
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- Theming & Dark Mode
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
- MobileβFriendly
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- Problem Management
- Tasks
- Assets
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
- Forms
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)