DEV Community

Cover image for PHP Thinks \"1e1\" Equals \"10\": switch vs match (the Loose Comparison Trap)
Muiduzzaman Lipu for Refactorly

Posted on

PHP Thinks \"1e1\" Equals \"10\": switch vs match (the Loose Comparison Trap)

In PHP, the string "1e1" is equal to the string "10", and that can send your switch statement down the wrong path. Here's why, how match fixes it, and three more PHP 8 features worth using.

Every example below was tested on PHP 8.1 and PHP 8.5, and both gave the same results.

Video version:

The trap

function check($v) {
    switch ($v) {
        case "10": return "ten";
        default:   return "other";
    }
}

check("1e1");   // "ten"
check("010");   // "ten"
check("10.0");  // "ten"
check(" 10");   // "ten"
check("10abc"); // "other"
Enter fullscreen mode Exit fullscreen mode

Four different strings, and all of them are treated as "10".

Why: switch compares with ==

switch uses loose comparison (==). When both strings look like numbers, PHP compares them as numbers:

"1e1" == "10"    // true  (1e1 is scientific notation: 1 ร— 10ยน = 10)
"1e1" === "10"   // false
Enter fullscreen mode Exit fullscreen mode

The same rule causes a famous security bug, known as "magic hashes":

md5('240610708')  // "0e462097431906509019562988736854"
md5('QNKCDZO')    // "0e830400451993494058024219903391"

md5('240610708') == md5('QNKCDZO')   // true ๐Ÿ˜ฑ
Enter fullscreen mode Exit fullscreen mode

Both hashes look like 0e<digits>, which is "0 ร— 10^something" = 0. So 0 == 0, and two completely different hashes are "equal". If a token or password-hash check uses ==, that's a real vulnerability. Use ===, or better, hash_equals() for hashes (it's also timing-safe).

PHP 8 did fix the worst one: "abc" == 0 is now false (it was true in PHP 7). But numeric strings are still compared as numbers.

match is strict

// switch
switch ($v) {
    case "10":
        $label = "ten";
        break;
    default:
        $label = "other";
}

// match (PHP 8.0+)
$label = match ($v) {
    "10"    => "ten",
    default => "other",
};
Enter fullscreen mode Exit fullscreen mode

match compares with ===, so "1e1" gives "other". So does the integer 10, because the types differ. It's also an expression: it returns a value, so seven lines become four, with no break.

No fall-through

The other classic switch bug is a missing break:

switch ($code) {
    case 200:
        $msg = "OK";       // no break!
    case 404:
        $msg = "Not found";
        break;
}
// $code = 200 โ†’ $msg is "Not found" โŒ
Enter fullscreen mode Exit fullscreen mode

match arms never fall through, and one arm can handle several values:

$msg = match ($code) {
    200      => "OK",
    404, 410 => "Not found",
};
Enter fullscreen mode Exit fullscreen mode

No match? It throws

echo match (500) { 200 => "OK", 404 => "Not found" };
// UnhandledMatchError: Unhandled match case 500
Enter fullscreen mode Exit fullscreen mode

switch silently does nothing when no case matches. match throws, so the bug shows up immediately instead of in production data.

Bonus: match (true) replaces long if/elseif chains:

$age = 25;
echo match (true) {
    $age < 13 => "child",
    $age < 20 => "teen",
    default   => "adult",
};   // "adult"
Enter fullscreen mode Exit fullscreen mode

3 more PHP 8 features to use

Nullsafe operator. You get null instead of an error when something in the chain is null:

$city = $user?->address?->city;
Enter fullscreen mode Exit fullscreen mode

Named arguments. Skip optional parameters, use any order, and make the call self-documenting:

str_pad("5", 3, pad_type: STR_PAD_LEFT, pad_string: "0");   // "005"
htmlspecialchars($html, double_encode: false);
Enter fullscreen mode Exit fullscreen mode

Enums. A fixed set of allowed values, and a perfect partner for match:

enum Status: string {
    case Active = 'active';
    case Banned = 'banned';

    public function label(): string {
        return match ($this) {
            Status::Active => 'Active โœ…',
            Status::Banned => 'Banned ๐Ÿšซ',
        };
    }
}

Status::from('active')->label();   // "Active โœ…"
Status::tryFrom('nope');           // null
Status::from('nope');              // ValueError: "nope" is not a valid backing value for enum Status
Enter fullscreen mode Exit fullscreen mode

Cheat sheet

  • switch uses ==, so "1e1" matches "10"
  • match uses ===, returns a value, and has no fall-through
  • No match means UnhandledMatchError instead of silence
  • Compare hashes with hash_equals(), never ==
  • Also use ?->, named arguments and enums

Are you still using switch, or have you moved to match? ๐Ÿ‘‡


I post one tested coding tip every day on YouTube at Refactorly. The 30-second version of this one is here.

Top comments (0)