PHP Enums and RBAC - edmozley/freeitsm GitHub Wiki
Why a capability is a Cap:: case and never a string. Since 4.0 Cap is a PHP 8.1 enum and every guard refuses a string; until then it was a class of constants, and this page argued about what an enum would add.
This page describes the code as it is now. An earlier version argued against a string-keyed design that no longer exists โ that design was replaced, largely because of the argument below. What's left is a narrower, and more honest, question than this page used to claim.
Companion pages: Roles โ Developer Guide (how to actually add a capability) and Raising the PHP floor to 8.1 (the decision).
A capability is an enum case, and every guard is typed to take one:
// includes/capabilities.php
enum Cap: string
{
case TICKETS_MAILBOXES = 'tickets.mailboxes';
case ASSETS_VCENTER = 'assets.vcenter';
// โฆ136 of them
}
function requireCapabilityJson(Cap $capability, ?PDO $conn = null): void// api/tickets/save_mailbox.php
requireCapabilityJson(Cap::TICKETS_MAILBOXES); // a string here is a TypeErrorThe case names were kept from the constants, so the 306 guard calls read exactly as they did. Data that holds a capability - manifests, settings_keys.php, a data-capability attribute - holds the stored string, written capKey(Cap::X); a string going back into a guard goes through Cap::from(), which throws on an unknown key. The rule, and why it is mechanical, is in Roles โ Developer Guide; tests/cap-enum-usage.php enforces it.
Everything else about a capability โ its module, its description, whether it's sensitive, which stored settings it guards โ is not written next to the constant. It lives in that module's settings manifest, and the registry is derived:
// includes/capabilities.php โ the registry is BUILT, not written
function capRegistry(): array
{
$registry = [];
foreach (settingsManifests() as $m) { // globbed from <module>/settings/manifest.php
foreach ($m['tabs'] as $tab) {
if ($tab['cap'] === null) continue; // a personal preference โ nothing to grant
$registry[$tab['cap']] = [
'module' => $m['module'],
'sensitive' => !empty($tab['sensitive']),
'label' => $tab['grant'],
];
}
}
return $registry;
}So a permission is written down exactly twice โ the name (an enum case) and the details (a manifest entry). The Roles picker, the module list, the tab bar and the setting-key ownership map are all generated from those two.
Suppose a capability were a plain string, and you mistyped one in a guard:
requireCapabilityJson('tickets.mailboxs'); // 'mailboxs'That does not error. It fails closed, exactly as the guard is designed to: HTTP 403, "You do not have permission to manage these settings." Which is indistinguishable from the system working correctly.
Three properties make that the dangerous kind of bug rather than an ordinary one.
It is invisible to you. analystHasCapability() short-circuits to true for is_admin before it ever compares the string:
function analystHasCapability(PDO $conn, int $analystId, Cap $capability): bool {
if ($analystId <= 0) return false;
if (analystIsAdmin($conn, $analystId)) return true; // โ never reaches the comparison
return in_array($capability->value, getAnalystCapabilities($conn, $analystId), true);
}(The code as it is in 4.0. Until then the parameter was string $capability, which is what made the typo below possible; the is_admin short-circuit is unchanged.)
You are an administrator. Every test you run passes. The is_admin bypass โ the thing that makes deny-by-default safe to switch on โ is also the thing that hides this class of bug from the only person who could fix it.
The user cannot tell it's a bug. They see a polite, well-worded 403 saying "you do not have permission." They don't file a bug report; they ask to be made an administrator. If someone obliges, a typo has quietly become a privilege escalation.
It never self-corrects. A crash gets fixed. A wrong number gets noticed. A permanent, silent, correct-looking denial can sit in a release for a year, logging nothing.
requireCapabilityJson(Cap::TICKETS_MAILBOXS);
// PHP Fatal error: Undefined constant Cap::TICKETS_MAILBOXSLoud, immediate, at the call site, on the first request that touches the line โ and, crucially, it fires for the administrator too, because the fatal happens before analystHasCapability() gets its chance to short-circuit. That asymmetry is gone.
One precision, so this isn't oversold: PHP resolves a constant fetch at runtime, not parse time, so php -l won't flag it. What you get is a runtime fatal on that line, the first time it runs โ plus your editor and any static analyser flagging it immediately, which a string literal is simply invisible to.
The rule the whole design rests on: always pass a
Cap::constant. Never write the string.
The rest of this page is the argument as made in July 2026, when the floor was 7.4. It is kept because its conclusion held: the one real gain was the typed parameter, and that is what 4.0 delivered.
Here an honest answer has to walk back what this page used to claim.
When the argument was first written, a capability's metadata โ module, label, sensitivity โ lived in a hand-maintained registry array, in parallel with the constants, in parallel with the tab layout, in parallel with the setting-key map. Four lists that had to agree. The enum's headline benefit was to collapse them onto one type:
enum Capability: string {
case TicketsMailboxes = 'tickets.mailboxes';
public function label(): string { return match($this) { โฆ }; }
public function isSensitive(): bool { return match($this) { โฆ }; }
public function module(): Module { return match($this) { โฆ }; }
}That benefit is already gone โ and we got it without enums. The four lists were collapsed into one by deriving the registry from the settings manifest. The label, the module and the sensitivity now live in exactly one place and cannot drift, because nothing else writes them down. An enum would move that metadata onto the type instead: a different home, not a better one โ and arguably a worse one, since the manifest keeps a tab's permission next to the tab it belongs to and the setting keys it guards.
| What an enum offers | What the code already has |
|---|---|
Capability::cases() |
capAll() โ derived from the manifests |
Capability::tryFrom($s) |
capFromKey($s) โ same job, same null-on-unknown, plus alias resolution for renamed keys |
$cap->label() / ->module() / ->isSensitive()
|
capLabel() / capModule() / capIsSensitive() โ reading the derived registry |
| A typo is caught at the call site | Already caught โ see above |
function requireCapabilityJson(string $capability, ?PDO $conn = null): void // until 4.0
function requireCapabilityJson(Cap $capability, ?PDO $conn = null): void // 4.0The guard's parameter is string. Passing Cap::TICKETS_MAILBOXES is how it is written, but requireCapabilityJson('anything') remains syntactically legal. The constant makes the right thing easy and the typo loud; it does not make the wrong thing impossible. An enum's signature would refuse a string outright, at every call site, permanently.
That was a real gap. It was also the only one โ and not a break-every-PHP-7.4-user gap, which is why the floor stayed at 7.4 in July 2026. The floor moved to 8.1 in October 2026 for a different reason (the code already needed it), and 4.0 then closed the gap: every guard - requireCapabilityJson, requireCapability, requireActionJson, analystHasCapability, analystMayAct, capActionRestricted, and the Domains and Files wrappers - is typed Cap.
What the swap turned out to cost that this page did not foresee: an enum compared with a string is silently false, so every place a capability is used as data needed capKey() (282 of them), checked by a tokenizer test rather than by eye. And Cap::X->value, the obvious spelling, is PHP 8 syntax that stops a page parsing on an old PHP - hence capKey().
Deliberately not done: a Module enum. Modules are discovered from each module's settings manifest by a glob; an enum would bring back the hand-kept second list the manifests were built to remove.
Honesty, since the rest of the page is an argument:
-
It does not stop copy-paste.
requireCapabilityJson(Cap::LMS_MANAGE)pasted into a Tickets endpoint is still wrong. The case makes it auditable โ "find usages ofCap::LMS_MANAGE" returns a list you can actually read โ but not impossible. -
It does not make the database type-safe.
rbac_role_capabilities.capability_keyis aVARCHAR, and someone with a MySQL client can still put junk in it.capFromKey()makes the code ignore junk deterministically, which is what actually matters. -
It does not write the guard for you. A missing
requireCapabilityJson()is still the single most likely permission bug, and no type system on earth catches a line you didn't write. That needs an audit โ which is what D005 is for, and it has repeatedly found real holes.
- Roles โ Developer Guide โ the rules you follow when adding a capability
- Roles & Permissions โ how the permission system works
- Raising the PHP floor to 8.1 โ the decision, the constants-to-enum bridge, and what 4.0 did
- PHP 8: the language features we can now use โ the wider tour