Programming

PHP 8 match Expression vs switch: Differences That Bite

By Nawawi JamiliPublished 6 min read

PHP 8.0 added match, and at first it looks like a shorter switch. It is not. It compares differently, it cannot fall through, it produces a value, and it throws an error when nothing matches. Those four differences are exactly what make it safer, and they also cause mistakes when you convert old switch blocks by hand.

Every example below was run on PHP 8.0.30 from the command line, and the output is pasted as it came out (file paths shortened).

Strict comparison: === instead of ==

switch compares with loose equality (==). match compares with strict identity (===), so the value and the type both have to agree. This difference causes the most problems, because the loose version usually works until an unusual value appears.

php
<?php
$input = '0';
switch ($input) {
    case false:
        echo "switch: false\n";
        break;
    case '0':
        echo "switch: string zero\n";
        break;
}
echo match ($input) {
    false => "match: false\n",
    '0'   => "match: string zero\n",
};
output
switch: false
match: string zero

The string '0' is falsy, so '0' == false is true and switch takes the first case, even though there is a case written for exactly that string. match skips false because a string is not a boolean, and reaches the arm you meant.

The opposite problem appears with user input. Everything that arrives from $_GET, $_POST, the command line or a database driver tends to be a string, and a strict comparison against an integer will never succeed:

php
<?php
$qty = $_GET['qty'] ?? '5';   // values from a form are strings
echo match ($qty) {
    5       => "five (int)\n",
    default => "no arm matched\n",
};
echo match ((int) $qty) {
    5       => "five (int)\n",
    default => "no arm matched\n",
};
output
no arm matched
five (int)

A switch would have matched '5' against 5 without complaint. With match, cast or validate the value first so its type is known before you compare it. I treat that as a feature: it forces the conversion to happen in one visible place.

No fall-through, no break

In a switch, forgetting a break means execution continues into the next case. Sometimes that is intended, but mostly it is a bug that stays hidden for years:

php
<?php
function label_switch(int $code): string
{
    $label = 'unknown';
    switch ($code) {
        case 200:
            $label = 'ok';
        case 404:
            $label = 'not found';
            break;
    }
    return $label;
}
echo label_switch(200), "\n";
output
not found

A match arm is a single expression and execution never moves into the next arm, so there is no break to forget. When you genuinely want several values to share a result, which is the legitimate use of fall-through, you list them in one arm separated by commas.

It returns a value

match is an expression, not a statement. You assign it, return it or pass it directly to a function, instead of setting a variable inside each case:

php
<?php
function status_text(int $code): string
{
    return match ($code) {
        200, 201, 204 => 'success',
        301, 302      => 'redirect',
        404           => 'not found',
        500, 503      => 'server error',
        default       => 'other',
    };
}
foreach ([200, 204, 302, 404, 418, 503] as $c) {
    printf("%d => %s\n", $c, status_text($c));
}
output
200 => success
204 => success
302 => redirect
404 => not found
418 => other
503 => server error

The comma-separated conditions (200, 201, 204) replace stacked case labels, and the trailing comma after the last arm is allowed, which keeps diffs clean when you add a line.

Because it is an expression, each arm holds exactly one expression. There is no block of statements. If an arm needs real work, call a function or method from it. You can wrap code in an immediately invoked closure, but if I find myself doing that, the code usually needs a proper function or a plain if.

UnhandledMatchError when nothing matches

A switch with no matching case and no default does nothing at all and continues. match does not guess: if no arm matches and there is no default, it throws UnhandledMatchError.

php
<?php
$level = 'verbose';
try {
    $n = match ($level) {
        'quiet'  => 0,
        'normal' => 1,
        'debug'  => 2,
    };
} catch (\UnhandledMatchError $e) {
    echo get_class($e), ': ', $e->getMessage(), "\n";
}
$n = match (5) { 1 => 'one', 2 => 'two' };
output
UnhandledMatchError: Unhandled match value of type string
PHP Fatal error:  Uncaught UnhandledMatchError: Unhandled match value of type int in /home/awie/match/unhandled.php:14
Stack trace:
#0 {main}
  thrown in /home/awie/match/unhandled.php on line 14

This is the behaviour I like most. A value you did not plan for becomes a clear error at the point where it happens. Without it, you get a variable that silently keeps its old value, often far away in the code. The wording of the message differs between PHP versions (8.0 says "match value", later releases say "match case"), so do not parse it.

Since PHP 8.0 throw is also an expression, so a default arm can throw an exception that means something in your application:

php
<?php
function port_for(string $scheme): int
{
    return match ($scheme) {
        'http'  => 80,
        'https' => 443,
        'ssh'   => 22,
        default => throw new InvalidArgumentException(
            "Unknown scheme: $scheme"
        ),
    };
}
echo port_for('https'), "\n";
try {
    port_for('gopher');
} catch (InvalidArgumentException $e) {
    echo $e->getMessage(), "\n";
}
output
443
Unknown scheme: gopher

The match(true) pattern for ranges

Arms do not have to be constants. Any expression works, so matching against true turns match into a compact if/elseif chain. The first arm whose condition is identical to true wins:

php
<?php
function grade(int $score): string
{
    return match (true) {
        $score >= 80 => 'A',
        $score >= 65 => 'B',
        $score >= 50 => 'C',
        default      => 'F',
    };
}
foreach ([92, 80, 70, 50, 12] as $s) {
    echo $s, ' => ', grade($s), "\n";
}
output
92 => A
80 => A
70 => B
50 => C
12 => F

Order matters, exactly as in an elseif chain. The trap is strictness again: the condition must evaluate to the boolean true, not merely something truthy. preg_match() returns the integer 1, so this does not do what it looks like:

php
<?php
$name = 'awie';
// preg_match() returns 1, not true
echo match (true) {
    preg_match('/^a/', $name) => "starts with a\n",
    default                   => "no match\n",
};
echo match (true) {
    preg_match('/^a/', $name) === 1 => "starts with a\n",
    default                         => "no match\n",
};
output
no match
starts with a

Comparison operators such as >= and === always return a boolean, so they are safe. Functions that return 1, a string or an array need an explicit comparison.

Arms are evaluated lazily, top to bottom

Conditions are checked in order and evaluation stops at the first match. Later conditions are never run:

php
<?php
function check(string $what): string
{
    echo "  evaluating $what\n";
    return $what;
}
$r = match ('b') {
    check('a') => 'first',
    check('b') => 'second',
    check('c') => 'third',
};
echo $r, "\n";
output
  evaluating a
  evaluating b
second

So put cheap or common conditions first, and do not rely on side effects in arms that may never be reached. Only the result expression of the winning arm runs.

When I still use switch

I use match whenever I am mapping a value to another value, which covers most of the switch blocks I used to write. I keep switch (or plain if) when a branch needs several statements. I also keep it when I really want loose comparison. That is rare, and it deserves a comment when it happens.

When converting old code, check three things. First, check the types of the subject and the case values (add a cast where input is a string). Second, check every intentional fall-through (turn it into a comma list). Third, check whether the code relied on a missing default to do nothing. If it did, add default => null or similar, or you will get UnhandledMatchError in production. The full rules are on the php.net match page.