Match Expressions

Learn PHP /Modern PHP 8.x /Lesson 3 of 8 /PHP 8.3 in your browser
In this lesson you’ll learn to
  • Replace a value-producing switch or if chain with a compact, strict match
  • Use match (true) to select a result from ranges and conditions
  • Decide when a default arm belongs and when omitting it is the safer choice

You met match briefly alongside switch. It deserves a lesson of its own, because it is not just a tidier switch: it is an expression that hands back a value, compares strictly, and refuses to silently do nothing. Those three traits make it the modern way to turn one value into another. The match expression has been part of PHP since version 8.0.

This builds on Readonly Properties, and picks up match from where you first saw it in Conditionals.

match Returns a Value

This is the difference that changes how you write code. A switch is a statement: it runs blocks and produces nothing you can capture, so each case has to echo or assign for itself. A match is an expression: the whole thing evaluates to a single value that you assign, return, or print directly. Compare the two shapes for the same task:

switch (a statement)

Produces nothing. You declare a variable first, then each case assigns to it, and every case needs a break.

$label = "";
switch ($code) {
    case 404:
        $label = "Not Found";
        break;
    default:
        $label = "Unknown";
}
match (an expression)

Evaluates to a value you assign in one go. No break, no repeated variable name, no fall-through.

$label = match ($code) {
    404 => "Not Found",
    default => "Unknown",
};

Here is a full, runnable version. Notice how $message is assigned the result of the whole match, that arms use =>, and that the closing brace is followed by a semicolon because the statement is really $message = ...;.

<?php
$statusCode = 404;
$message = match ($statusCode) {
    200, 201 => "OK",
    301, 302 => "Redirect",
    404 => "Not Found",
    500 => "Server Error",
    default => "Unknown",
};
echo $message;
Not Found

Because $statusCode is 404, PHP walks the arms top to bottom, finds the 404 arm, and the entire match becomes "Not Found". That value lands in $message. There is no break to forget and no way to accidentally fall into the next arm.

Strict Comparison, No Type Juggling

A switch compares with loose ==, so it quietly converts types before matching. A match compares with strict ===, so the value and the type must both agree. This is the single most useful safety property of match, and it is where it disagrees with switch on the same input. Predict this one before you run it:

What does this print?

<?php
$value = 5;
echo match ($value) {
    "5" => "string five",
    5   => "number five",
    default => "no match",
};
  • number five
  • string five
  • no match

The subject $value is the integer 5. Because match uses strict ===, the string arm "5" is not a match (a string is never identical to an integer), so PHP skips it and takes the integer 5 arm.

A switch would answer differently: switch (5) with a case "5" would fire, because loose == juggles the string into a number. That mismatch is a real bug source, so the rule of thumb is: when the exact type matters, reach for match.

The string-versus-number trap. switch ("1") will run case 1 because == treats them as equal, but match ("1") will not take an arm of 1. If your value could arrive as a string (form input and URL parameters always do), match against arms of the same type, or convert the value first with (int).

Several Values Per Arm

When different inputs should produce the same result, list them on one arm separated by commas. It reads as “any of these.” Each value is still compared strictly, and the first arm that contains a match wins.

<?php
$level = "editor";
$access = match ($level) {
    "admin", "owner"   => "full",
    "member", "editor" => "limited",
    default            => "none",
};
echo $access;
limited

Both "admin" and "owner" map to "full", and both "member" and "editor" map to "limited". Grouping values this way replaces the old switch pattern of stacking bare case labels on top of each other to share a block, and it does it in one clear line.

Matching On Conditions With match(true)

So far every arm has tested “is the subject equal to this value?” But sometimes you need ranges or richer tests, not equality. The trick is to make the subject true and let each arm be a boolean condition. PHP finds the first arm whose condition evaluates to true. This turns match into a clean replacement for a long if/elseif ladder that produces a value.

Reading a grade from a score is three steps:

  1. Set the subject to true
  2. Write each threshold as a condition, strictest first
  3. Let the first true condition supply the grade
<?php
$score = 82;
$grade = match (true) {
    $score >= 90 => "A",
    $score >= 80 => "B",
    $score >= 70 => "C",
    default      => "F",
};
echo $grade;
B

Order matters here, exactly like an if chain. What would this print if the $score >= 70 arm came first? It would print C, because 82 >= 70 is true and match stops at the first true arm, never reaching the B line. Always list the tightest condition first.

The same pattern handles any set of conditions, not just numbers. Each arm can be any boolean expression:

<?php
$temperature = 3;
echo match (true) {
    $temperature <= 0 => "freezing",
    $temperature < 15 => "cold",
    $temperature < 25 => "mild",
    default           => "warm",
};
cold

Advanced: each arm is a single expression. A match arm holds one expression whose value becomes the result. It cannot contain several statements the way a switch case block can. When a branch genuinely needs to run several lines of logic, either call a function from the arm (the call is still one expression) or keep that branch in an if. Trying to force multi-step work into an arm is a sign the job wants a function, not a bigger match.

UnhandledMatchError Is a Feature

If no arm matches and there is no default, match throws an UnhandledMatchError instead of quietly doing nothing. A switch with no matching case and no default simply falls through and runs nothing, so a typo or a value you forgot slips by unnoticed. match refuses to guess. That loud failure is the point: it tells you about a case you did not handle rather than shipping a silent gap.

<?php
$httpStatus = 500;
try {
    $label = match ($httpStatus) {
        200 => "OK",
        404 => "Not Found",
    };
    echo $label;
} catch (\UnhandledMatchError $e) {
    echo "Caught: ", $e->getMessage();
}
Caught: Unhandled match case 500

There is no arm for 500 and no default, so PHP throws. Catching the error lets you see the message name the exact unhandled value. In real code you rarely catch it: you either add the missing arm or add a default so every input has a home.

Add default when input is open-ended; leave it off when it is closed. For user input or status codes where new values can appear, a default arm gives a sensible fallback. But when you are matching over a fixed, known set (every case of an enum, for example), leaving out default is deliberate: if you later add a case and forget to handle it, UnhandledMatchError points straight at the omission.

Why does match throw an UnhandledMatchError when nothing matches, instead of doing nothing like switch?

  • To force you to handle every case, so a forgotten value fails loudly instead of slipping by
  • Because match cannot have a default arm
  • Because strict comparison is slower and times out

A silent no-op hides bugs. By throwing on an unhandled value, match makes the gap visible. Add a default arm when a fallback is what you want, or add the missing arm when it is not.

Your Turn

Use match (true) to turn a number into its FizzBuzz label. The variable $n holds 30. Print Fizz when it divides by 3, Buzz when it divides by 5, and FizzBuzz when it divides by both, otherwise the number itself. Order matters: test divisible-by-15 first. With $n = 30 the output should be exactly FizzBuzz. Then hit Check my answer.

<?php
$n = 30;
<?php
$n = 30;
echo match (true) {
    $n % 15 === 0 => "FizzBuzz",
    $n % 3 === 0  => "Fizz",
    $n % 5 === 0  => "Buzz",
    default       => (string) $n,
};

What You Learned

  • match is an expression: it evaluates to a value you assign or return, with no break and no fall-through
  • It compares with strict ===, so unlike switch it never juggles types (match ("1") will not hit an arm of 1)
  • One arm can list several comma-separated values, and match (true) lets each arm be a condition for ranges and richer tests
  • With no matching arm and no default, match throws UnhandledMatchError, turning a forgotten case into a loud error instead of a silent gap

You can now:

  • Replace a value-producing switch or if chain with a compact, strict match
  • Use match (true) to select a result from ranges and conditions
  • Decide when a default arm belongs and when omitting it is the safer choice

You have been calling functions like match-driven helpers with plain positional arguments. Next you will learn named arguments, an 8.0 feature that lets you pass values by parameter name so calls like createUser(name: "Billy", admin: true) read clearly and skip the optional parameters you do not care about.

Comments

Leave a reply

You’ll learn to
  • Replace a value-producing switch or if chain with a compact, strict match
  • Use match (true) to select a result from ranges and conditions
  • Decide when a default arm belongs and when omitting it is the safer choice
Next: Named Arguments →