Skip to content

PHP 8 Language Features

Ed Mozley edited this page Jul 13, 2026 · 1 revision

PHP 8: the language features we can't use

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.


The 101: what 8.0 and 8.1 actually added

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.

2.1 match expressions (PHP 8.0)

What it is. switch's replacement. Three differences that matter:

  1. It's an expression β€” it returns a value, so you can assign it. switch is a statement; it can only do things.
  2. It compares with ===, not ==. No type juggling. switch ("0") matching case 0 cannot happen.
  3. There is no fall-through and no implicit default. If nothing matches and you wrote no default, PHP throws \UnhandledMatchError. A switch with a forgotten case silently does nothing; a match with 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.

2.2 Enums (PHP 8.1) β€” the big one

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::Duplicate is a value of type LinkRelation. A function typed f(LinkRelation $r) cannot be called with 'dupllicate'. It's a TypeError β€” 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 throws ValueError for an unknown string. ::tryFrom('parent') returns the case or null. 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 : string backing, 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.

2.3 Constructor property promotion (PHP 8.0)

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.

2.4 readonly properties (PHP 8.1)

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.

2.5 Union types (PHP 8.0)

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

The ?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."

2.6 Named arguments (PHP 8.0)

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.

2.7 throw as an expression (PHP 8.0)

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']);

2.8 The nullsafe operator ?-> (PHP 8.0)

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.

2.9 First-class callable syntax (PHP 8.1)

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 creation

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

2.10 The never return type (PHP 8.1)

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.

2.11 The small stuff you'd stop hand-rolling

  • str_contains(), str_starts_with(), str_ends_with() (8.0) β€” the end of strpos($h, $n) !== false, whose 0-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 the catch (Throwable $e) { return []; } fail-closed blocks in includes/rbac.php:103 where $e is never used.
  • Attributes (8.0) β€” structured metadata on declarations. Interesting long-term (route/permission annotations), not something to reach for now.

What else in FreeITSM would visibly improve

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.



See also

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally