-
Notifications
You must be signed in to change notification settings - Fork 15
PHP 8 Language Features
A 101 tour of what PHP 8.0 and 8.1 added, written for someone who has never used them, with before/after drawn from FreeITSM's actual code. Each feature gets: what it is, what problem it solves, and whether it would genuinely help us β including the ones that wouldn't, said plainly rather than padded.
This is the reference half of Raising the PHP floor to 8.1, which is where the decision, the cost and the verdict live. The single feature that actually drives that argument β enums β has its own page: PHP enums and the RBAC capability system.
We are on a PHP 7.4 floor, so none of this is available to us today. That is a deliberate, documented decision (see the decision page), not an oversight.
This section assumes you've never written PHP 8 code. Each feature gets: what it is, what problem it solves, and a short before/after drawn from FreeITSM's real code where a real example exists. Where a feature genuinely wouldn't help us much, I say so rather than padding.
What it is. switch's replacement. Three differences that matter:
- It's an expression β it returns a value, so you can assign it.
switchis a statement; it can only do things. - It compares with
===, not==. No type juggling.switch ("0")matchingcase 0cannot happen. -
There is no fall-through and no implicit default. If nothing matches and you wrote no
default, PHP throws\UnhandledMatchError. Aswitchwith a forgotten case silently does nothing; amatchwith a forgotten case shouts.
That third point is the real prize. Silent-nothing is how permission bugs and routing bugs live for months.
Real example β includes/messaging/messaging.php:32-42:
// BEFORE (7.4)
function messagingProvider(array $channel): MessagingProvider
{
switch ($channel['provider'] ?? 'twilio') {
case 'twilio':
return new TwilioProvider($channel);
case 'meta':
return new MetaCloudProvider($channel);
default:
throw new Exception('Unknown messaging provider: ' . ($channel['provider'] ?? '?'));
}
}// AFTER (8.0)
function messagingProvider(array $channel): MessagingProvider
{
return match ($channel['provider'] ?? 'twilio') {
'twilio' => new TwilioProvider($channel),
'meta' => new MetaCloudProvider($channel),
default => throw new Exception('Unknown messaging provider: ' . ($channel['provider'] ?? '?')),
};
}Shorter, but more importantly: break can no longer be forgotten, and (see 8.7) throw is now an expression so it fits inside the match arm.
Other switch blocks that are pure value-mapping and would become match: includes/ticket_links.php:208, workflow/includes/engine.php:855, :1396, :1450, includes/service_context.php:101, api/tickets/request_csat.php:46.
What it is. A type whose values are a closed, named set. Not a class of constants, not an array of strings β a genuine type the engine enforces.
enum LinkRelation: string // "string-backed": each case has a string value
{
case Related = 'related';
case Duplicate = 'duplicate';
case Parent = 'parent';
}Things you get, for free, that a const or an array of strings can never give you:
-
LinkRelation::Duplicateis a value of typeLinkRelation. A function typedf(LinkRelation $r)cannot be called with'dupllicate'. It's aTypeErrorβ at the call site, immediately, loudly. -
LinkRelation::cases()returns every case, in declaration order. One place to add a case; every dropdown, validator and picker updates itself. -
LinkRelation::from('parent')returns the case, or throwsValueErrorfor an unknown string.::tryFrom('parent')returns the case ornull. This is your DB-boundary converter: rows in, typed values out, garbage rejected at the door. - Enums can have methods. The value, its human label, its module, its icon and its danger flag can all live on one type instead of being scattered across four arrays in three files.
-
String-backed enums are JSON-serializable automatically β
json_encode(LinkRelation::Parent)gives"parent". (Pure enums, without a: stringbacking, are not.)
The catch, and it's a small one: PDO cannot bind an enum directly. You pass $relation->value. That's a one-token cost at exactly one layer (the query), and it's the layer where the string belongs anyway.
Enums are the reason the floor question exists at all. PHP enums and the RBAC capability system is the full argument.
What it is. Declaring a constructor parameter as a property, in one place, instead of three (declare, param, assign).
Real example β includes/messaging/MessagingProvider.php:24-31:
// BEFORE (7.4) β three lines saying "channel" for one idea
abstract class MessagingProvider
{
/** @var array decrypted messaging_channels row */
protected $channel;
public function __construct(array $channel)
{
$this->channel = $channel;
}
}// AFTER (8.0)
abstract class MessagingProvider
{
public function __construct(protected array $channel) {}
}The visibility keyword in the parameter list is what promotes it. Note also that the promoted version is typed (array), where the 7.4 version could only say so in a docblock the engine ignores.
What it is. A property that can be written exactly once, from inside the declaring class (in practice: in the constructor), and never again. Any later write is an Error. It's immutability enforced by the engine rather than by everyone remembering.
Combined with promotion:
abstract class MessagingProvider
{
public function __construct(protected readonly array $channel) {}
}$this->channel now holds decrypted Twilio credentials. readonly makes it structurally impossible for a subclass, a trait or a careless method to mutate them mid-request. That's not a stylistic win β for a credentials bag it's a small, real safety property.
What it is. int|string in a signature: "this parameter is one of these types, and the engine will check it." Before 8.0, if a parameter could legitimately be two types, your only option was to type nothing at all and describe it in a comment.
Real example β includes/messaging/messaging.php:49:
function loadMessagingChannel(PDO $conn, $channelId): ?array // 7.4: $channelId is a mystery
function loadMessagingChannel(PDO $conn, int|string $channelId): ?array // 8.0The ?array return type there is 7.1+, so we already had half of this. Unions close the other half. 8.0 also adds static as a return type (for fluent APIs) and mixed as an explicit "yes, really, anything."
What it is. Passing arguments by parameter name instead of by position:
$stmt = someHelper(conn: $conn, ticketId: 42, includeDeleted: false);Why it matters here specifically: FreeITSM is full of options arrays, which are a poor man's named arguments. includes/ai_provider.php:45:
// BEFORE (7.4) β $opts is a bag; its keys are documented in a comment and validated nowhere
function aiProviderChat(array $cfg, array $opts): array
{
$opts['max_tokens'] = $opts['max_tokens'] ?? 1024;
$opts['temperature'] = $opts['temperature'] ?? 0.0;
...
}
aiProviderChat($cfg, ['system' => $sys, 'user' => $prompt, 'max_tokens' => 2048]);// AFTER (8.0) β real parameters, real types, real defaults, and you still get
// to skip the middle ones and label what you're passing
function aiProviderChat(
AiConfig $cfg,
string $system,
string $user,
int $maxTokens = 1024,
float $temperature = 0.0,
?string $referer = null,
?string $title = null,
): AiResult { ... }
aiProviderChat($cfg, system: $sys, user: $prompt, maxTokens: 2048);A typo'd 'max_token' key in the old form is silently ignored and you quietly get 1024 tokens. A typo'd maxToken: argument is an immediate Error: Unknown named parameter. Same shape of bug as the RBAC capability one; same fix.
What it is. In 7.4, throw is a statement β it can't go where a value is expected. In 8.0 it's an expression, so it slots into ??, ?:, arrow functions and match arms. This is what makes the "validate or die" one-liner possible, and it pairs directly with enums (see enums and RBAC):
$cap = Capability::tryFrom($row['capability_key'])
?? throw new RuntimeException('Unknown capability: ' . $row['capability_key']);What it is. $a?->b()?->c short-circuits to null the moment anything in the chain is null, instead of fatalling on "call to a member function on null."
Honest assessment: this one earns us almost nothing. FreeITSM is an array-heavy, procedural codebase β data comes out of PDO as associative arrays and stays that way. ?-> is for deep object graphs, which we don't have. ?? already covers the $row['x'] ?? default pattern we actually use, and that's been available since 7.0. Listing it for completeness; not selling it.
What it is. strlen(...) β with a literal ... β creates a Closure from a function or method, replacing the stringly-typed 'strlen' or [$obj, 'method'] array form. The string form has no IDE support, no rename-refactor, and no error until it's invoked.
$ids = array_map('intval', $raw); // 7.4 β 'intval' is just a string
$ids = array_map(intval(...), $raw); // 8.1 β a real callable; a typo is a fatal at creationGenuinely useful, genuinely minor for us. We mostly use arrow functions (fn($c) => ..., e.g. setup/index.php:123) which have been fine since 7.4.
What it is. A return type meaning "this function does not return." It either throws, exits, or loops forever. It is not void β void means "returns nothing"; never means "control never reaches the line after the call."
This one is tailor-made for our guard helpers, which all end in exit. Today requireCapabilityJson() (includes/rbac.php:150) inlines its 401 and 403 bodies. Extracted:
function denyJson(int $status, string $message): never
{
http_response_code($status);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => $message]);
exit;
}Now both the engine and any static analyser know that code after denyJson(...) is unreachable, if (!$ok) denyJson(403, 'β¦'); needs no else, and a reviewer reading a guard cannot misread it as "logs a warning and carries on" β which is precisely the misreading that produces an auth bypass.
-
str_contains(),str_starts_with(),str_ends_with()(8.0) β the end ofstrpos($h, $n) !== false, whose0-is-falsy trap has bitten every PHP codebase ever written. -
array_is_list()(8.1) β one call for "is this a JSON array or a JSON object", which we currently answer by hand in a couple of API validators. - Non-capturing
catch (Throwable)(8.0) β for thecatch (Throwable $e) { return []; }fail-closed blocks inincludes/rbac.php:103where$eis never used. - Attributes (8.0) β structured metadata on declarations. Interesting long-term (route/permission annotations), not something to reach for now.
Beyond RBAC, these are the concrete places I found where the 7.4 floor is currently costing us:
| Where | Today | With 8.1 |
|---|---|---|
includes/ai_provider.php:27,45-87 |
const AI_PROVIDER_VALID = ['anthropic','openai','openrouter']; plus an in_array() validator, plus an if ($provider === 'anthropic') β¦ else { $base = $provider === 'openrouter' ? β¦ : β¦; } chain |
enum AiProvider: string with ->baseUrl(), ->wireFormat(), ->extraHeaders(). The in_array validator becomes AiProvider::from(). The provider fan-out becomes a match. Adding a fourth provider is one case + arms the compiler makes you fill in. |
includes/messaging/messaging.php:32-42 |
switch on $channel['provider'] returning new TwilioProvider / new MetaCloudProvider / throw
|
enum MessagingProviderKind: string + match; the default: throw becomes automatic via UnhandledMatchError
|
includes/messaging/MessagingProvider.php:24-31 |
protected $channel; (untyped) + a 3-line constructor holding decrypted credentials
|
public function __construct(protected readonly array $channel) {} β one line, typed, immutable |
The mailbox provider triple β 'microsoft' | 'google' | 'imap' β repeated as bare strings in api/tickets/send_email.php:81,130, api/tickets/save_mailbox.php:28-35, includes/template_email.php:46-52, tickets/settings/index.php:3034-3040
|
Four files independently know the same three magic strings, each with its own ?? 'microsoft' default. There is no single place that says what a provider is. |
enum MailboxProvider: string { case Microsoft; case Google; case Imap; } with ->usesOAuth(): bool, ->label(): string, ->badgeColour(): string. The JS badge in tickets/settings/index.php:3033-3040 gets fed from json_encode(MailboxProvider::cases()) instead of a hand-written if/else in JavaScript that duplicates the PHP. |
includes/ticket_links.php:208-219 |
switch ($r['relation_type']) with 'parent' / 'duplicate' / implicit-default-is-related
|
enum LinkRelation: string + match, and the "everything else is related" default becomes a deliberate LinkRelation::Related => arm rather than a default: that also swallows typos |
includes/rbac.php:103, and the other fail-closed catches |
catch (Throwable $e) { return []; } β $e unused, so the reader wonders if it should have been logged |
catch (Throwable) β non-capturing; says "we are deliberately discarding this" |
Every guard that ends in exit β includes/rbac.php:129-169, includes/admin_api_guard.php
|
: void, which is a lie: control does not return |
: never on the extracted denyJson() / denyPage() helpers |
setup/index.php:99 |
(float)$phpVersion >= 7.4 |
PHP_VERSION_ID >= 80100 (fixes the 8.10 float bug today, floor or no floor) |
| Ticket statuses / priorities |
Correctly not an enum candidate β these are user-editable rows in ticket_statuses, not a closed set the code knows. Enums are for sets the code owns. Calling this out because it's the obvious-looking candidate that would be wrong. |
The pattern across all of these is the same: wherever the code owns a closed set of strings, we currently express it as an array plus a validator plus a switch plus a label lookup β four things that must agree, verified by nothing. An enum is one thing.
- Raising the PHP floor to 8.1 β the case, the cost, and the decision
- PHP enums and the RBAC capability system β the one feature the whole argument turns on
- Roles β Developer Guide β how capabilities are written today, on 7.4
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
- β³ π’ Ticket numbering
- β³ π 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)