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?
answer
- database and request values arrive as strings
- switch matched '2' == 2 loosely
- match needs identical types
- cast or map at the boundary
- production message: of type string
basics
~20 sswitch 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 sThe 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
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 transitgo deeper
Know that match compares with === and throws UnhandledMatchError when no arm matches, so a string '2' does not match the arm 2.
Explain how switch's loose comparison hid the string inputs, and read the error message, including the production form that reports only the type.
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.
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.