skip to content

After replacing a switch with match in a PHP 8 parcel tracker, status codes read from the database start throwing UnhandledMatchError. Why, and how do you fix it safely?

level: seniorimportance: should knowfreq 30%

answer

  1. database and request values arrive as strings
  2. switch matched '2' == 2 loosely
  3. match needs identical types
  4. cast or map at the boundary
  5. production message: of type string

basics

~20 s

switch compared the database string '2' loosely with case 2; match uses ===, so '2' matches no int arm and throws UnhandledMatchError. Validate and cast at the boundary, then decide deliberately how unknown codes should fail.

solid answer

~50 s

The old `switch` compared with **loose `==`**, so a status code that arrived as the string `'2'`, as values often do from a database driver, a query string or a CSV import, still matched `case 2:`. `match` compares with **strict `===`**, so `'2'` matches none of the `int` arms, and with no `default` PHP throws `UnhandledMatchError: Unhandled match case '2'`. The loose comparison had been hiding a type mismatch all along. The safe fix is to **normalise at the boundary**: validate and cast once where the value enters, `(int)` after a digit check or a validated mapping, so the rest of the code sees real integers. Then decide explicitly what unknown codes should do: keep `match` exhaustive and let a truly unknown code fail loudly, or add a `default` that logs and returns a fallback label. Do not add string arms like `'2', 2 =>` everywhere; that spreads the type confusion.

code

php · 19 lines
php
<?php
declare(strict_types=1);

function statusLabel(int $code): string
{
    return match ($code) {
        1 => 'Label created',
        2 => 'In transit',
        3 => 'Delivered',
        default => throw new DomainException("Unknown parcel status {$code}"),
    };
}

$raw = '2'; // e.g. a column value returned as a string
if (!ctype_digit($raw)) {
    throw new UnexpectedValueException('Status code must be digits');
}

echo statusLabel((int) $raw), PHP_EOL; // In transit

go deeper

for a junior

Know that match compares with === and throws UnhandledMatchError when no arm matches, so a string '2' does not match the arm 2.

for a middle

Explain how switch's loose comparison hid the string inputs, and read the error message, including the production form that reports only the type.

for a senior

Fix types once at the boundary with validation and a cast, add string-input tests, and choose deliberately between failing loudly and a logged default.

for a principal

Plan a switch-to-match migration across a codebase: inventory where subjects originate, sequence boundary fixes first, and agree a policy for unknown values.

## The symptom A parcel tracker maps numeric status codes to labels. The original code: ```php switch ($row['status']) { case 1: return 'Label created'; case 2: return 'In transit'; case 3: return 'Delivered'; } ``` A refactor to `match` looks harmless: ```php return match ($row['status']) { 1 => 'Label created', 2 => 'In transit', 3 => 'Delivered', }; ``` In tests with hand-written integers everything passes. In production, every request fails with `UnhandledMatchError`. ## The cause: loose versus strict comparison Values from outside the program very often arrive as **strings**: query parameters and form fields always do, CSV cells do, and many database drivers return numeric columns as strings depending on their configuration. The `switch` compared with `==`, and `'2' == 2` is true because a numeric string is compared as a number. So the old code "worked" while silently relying on type juggling. `match` compares with `===`. `'2' === 2` is false, no arm matches, there is no `default`, and PHP throws **`UnhandledMatchError`**, a subclass of `Error`. With development settings, the message shows the value, `Unhandled match case '2'`; the quotes reveal that it is a string. With the production `php.ini`, which sets `zend.exception_ignore_args = On`, the message becomes `Unhandled match case of type string`, which still tells you the type is wrong. The same trap occurs with `switch` in reverse: numeric strings compare numerically in `switch`, so `'1'` matches an earlier `case '01':`, as a php-src test demonstrates. Loose comparison makes both directions unpredictable. ## Fixing it at the right place The goal is that code past the boundary only ever sees integers. 1. **Validate and cast where the value enters.** Check it looks like an integer, then cast: `ctype_digit($raw) ? (int) $raw : throw new UnexpectedValueException(...)`. A bare `(int)` on garbage yields `0`, so validate first. 2. **Type the boundary.** A function `statusLabel(int $code): string` in a file with `declare(strict_types=1);` makes callers pass real integers; a string arriving from a caller in strict mode is a `TypeError` at the call, not a mystery deeper in. 3. **Consider a backed enum** for the status set; converting the raw value through the enum centralises validation. How enums work belongs with enums; the point here is that the conversion happens once. 4. **Configure the data source** to return native types where it can, but still validate: configuration drifts. What not to do: - **Do not** duplicate arms as `2, '2' => 'In transit'`. It hides the type problem and has to be repeated in every `match`. - **Do not** "fix" it by going back to `switch`; the loose comparison will keep masking other type bugs. ## Deciding what unknown codes should do `match` forces a decision that `switch` let you skip: | Choice | Effect | When it fits | |---|---|---| | no `default`, let `UnhandledMatchError` propagate | unknown status fails the request loudly | the set of codes is closed and a new one means a deployment is missing | | `default => throw new DomainException(...)` | same, but with a domain-specific message | you want a clearer error for logs and alerts | | `default => 'Unknown status'` plus logging | page renders, the gap is recorded | a new carrier code must not take down the tracking page | The worst option is the one `switch` gave by default: silently returning nothing. ## Rolling the change out safely - Search for every `switch` being converted and check where its subject comes from; request and database inputs need the boundary fix first. - Add tests that feed **string** inputs, not just integers, so the difference between `==` and `===` is exercised. - Watch error logs for `UnhandledMatchError` after the deploy; each one is either a type bug or an unmapped code. The deeper lesson for a senior answer: `match` did not introduce the bug, it **exposed** a type mismatch that loose comparison had been absorbing. The right fix makes types explicit where data enters, not looser where it is used.

  • Why is (int) $raw alone not a safe fix for the status code?
    Casting never fails: `(int) 'abc'` is `0` and `(int) '2x'` is `2`, so garbage turns into a plausible code instead of an error. Validate first, for example with `ctype_digit()` or `filter_var()` with `FILTER_VALIDATE_INT`, which returns `false` for invalid input, and only then use the integer.
  • Why does the production error message say 'Unhandled match case of type string' without the value?
    The production `php.ini` sets `zend.exception_ignore_args = On`, and with that setting the engine leaves the value out of the `UnhandledMatchError` message and reports only its type. It still tells you the subject was a string where integers were expected, which is usually enough to find the boundary that lacks a cast.

saying these in an interview costs you the question

  • Blames match for being buggy instead of the string input it receives.
  • Fixes it by adding '2' and 2 as duplicate conditions in every arm.
  • Casts with (int) without validating, so bad input becomes status 0.
  • Says reverting to switch is the safe fix because it worked before.
  • Believes UnhandledMatchError extends Exception, so catch (Exception) handles it.