Advanced PHP Language

Match Expressions

The match expression selects and returns a value based on strict comparison. PHP introduced it in PHP 8.0. It resembles switch, but it uses identity checks, has no fall-through, returns a value, and must handle every possible subject value or throw UnhandledMatchError.

Use match when one input maps clearly to one result. It is especially useful for status labels, enum behavior, format selection, and small decision tables.

Official reference: PHP match expression.

Return A Value Directly

A match expression can be assigned or returned:

PHP example
<?php

declare(strict_types=1);

function statusLabel(string $status): string
{
    return match ($status) {
        'pending' => 'Waiting for payment',
        'paid' => 'Ready to dispatch',
        'cancelled' => 'Order cancelled',
        default => 'Unknown status',
    };
}

echo statusLabel('paid'), PHP_EOL;

// Prints:
// Ready to dispatch

Each arm has one or more conditions on the left and one result expression on the right. The entire construct ends with a semicolon because it is an expression.

Unlike a long if chain that assigns the same variable repeatedly, match makes the returned value visible in one place.

Comparisons Are Strict

match uses ===. It distinguishes values that loose comparison may consider equal:

PHP example
<?php

declare(strict_types=1);

function describeCode(int|string $code): string
{
    return match ($code) {
        0 => 'integer zero',
        '0' => 'string zero',
        1 => 'integer one',
        '1' => 'string one',
        default => 'another value',
    };
}

echo describeCode(0), PHP_EOL;
echo describeCode('0'), PHP_EOL;

// Prints:
// integer zero
// string zero

This avoids surprising coercion, but input parsing still matters. A form field normally arrives as a string. Parse it deliberately before matching against integer arms.

Do not add duplicate-looking arms without understanding types. Static analysis and tests should cover boundary inputs.

There Is No Fall-Through

A switch case can continue into a later case when break is omitted. match evaluates only the selected arm's result.

PHP example
<?php

declare(strict_types=1);

function shippingGroup(string $countryCode): string
{
    return match ($countryCode) {
        'GB', 'IE' => 'region-west',
        'DE', 'FR', 'NL' => 'region-central',
        default => 'manual-review',
    };
}

Comma-separated conditions share one result. They are an OR relationship. No break statements are required.

This makes accidental fall-through impossible and keeps the mapping compact.

Match Must Be Exhaustive

If no arm matches and there is no default, PHP throws UnhandledMatchError:

PHP example
<?php

declare(strict_types=1);

try {
    $label = match ('refunded') {
        'pending' => 'Pending',
        'paid' => 'Paid',
    };
} catch (UnhandledMatchError $exception) {
    echo 'Status was not handled', PHP_EOL;
}

// Prints:
// Status was not handled

Exhaustiveness is useful. It exposes a missing case rather than silently returning null or retaining an old variable value.

Whether to include default is a design decision. Use it for genuinely open input, such as an external status string where unknown values have a defined fallback. Omit it for closed sets when an unhandled value should expose missing code.

Match Works Especially Well With Enums

Enums define a closed set of cases. A match without default makes missing behavior visible when a new case is added:

PHP example
<?php

declare(strict_types=1);

enum PaymentState
{
    case Pending;
    case Paid;
    case Failed;
}

function customerMessage(PaymentState $state): string
{
    return match ($state) {
        PaymentState::Pending => 'Your payment is processing.',
        PaymentState::Paid => 'Payment received.',
        PaymentState::Failed => 'Payment failed.',
    };
}

echo customerMessage(PaymentState::Paid), PHP_EOL;

A default arm would hide a new enum case by treating it as generic. For a closed domain type, exhaustive explicit arms are often better.

If the behavior belongs intrinsically to the enum and is reused widely, an enum method may be clearer than repeated external match expressions.

Arm Results Are Expressions

The right side must be an expression, not a block of statements. Function calls, object construction, arrays, arithmetic, and throw expressions are valid:

PHP example
<?php

declare(strict_types=1);

function normaliseFormat(string $format): string
{
    return match (strtolower(trim($format))) {
        'json' => 'application/json',
        'csv' => 'text/csv',
        'xml' => 'application/xml',
        default => throw new InvalidArgumentException('Unsupported format.'),
    };
}

Do not hide a large workflow inside immediately invoked closures merely to force statement blocks into match. Extract a named function or use ordinary control flow.

Arm expressions are evaluated lazily. PHP evaluates conditions in order and evaluates only the selected result expression. Avoid relying on side effects in conditions because they make ordering significant and difficult to read.

Use match (true) For Ordered Predicates

Matching on true allows boolean conditions:

PHP example
<?php

declare(strict_types=1);

function deliveryBand(int $weightGrams): string
{
    if ($weightGrams < 0) {
        throw new InvalidArgumentException('Weight cannot be negative.');
    }

    return match (true) {
        $weightGrams === 0 => 'empty',
        $weightGrams <= 500 => 'small',
        $weightGrams <= 2_000 => 'medium',
        default => 'large',
    };
}

echo deliveryBand(750), PHP_EOL;

// Prints:
// medium

Order matters. The first true condition wins. Put narrow or lower ranges before broader ones. Validate impossible input separately so a default arm does not misclassify it.

match (true) is useful for a short ordered classification. A complex set of overlapping business rules may be clearer as named predicates, a rules collection, Strategy objects, or an explicit decision table.

Do Not Confuse Truthy With Strict True

Because matching is strict, each condition in match (true) must evaluate to boolean true, not merely a truthy non-boolean value.

Some PHP functions return values such as 1, 0, or false. Convert intentionally:

PHP example
<?php

declare(strict_types=1);

function detectLanguage(string $text): string
{
    return match (true) {
        str_contains($text, 'Bonjour') => 'fr',
        str_contains($text, 'Hello') => 'en',
        default => 'unknown',
    };
}

str_contains() returns bool, so it is a natural fit. For a function with a mixed result, compare explicitly rather than relying on truthiness.

Match Versus Switch

Prefer match when:

  • the decision produces one value;
  • strict comparison is correct;
  • no fall-through is needed;
  • the cases form a compact mapping;
  • exhaustiveness is valuable.

A switch can still be useful in legacy code or when maintaining an existing statement-oriented structure. New code should not be converted mechanically if the behavior relies on fall-through or several statements per case.

This switch shape:

PHP example
<?php

declare(strict_types=1);

function legacyLabel(string $status): string
{
    switch ($status) {
        case 'paid':
            return 'Paid';
        case 'failed':
            return 'Failed';
        default:
            return 'Unknown';
    }
}

can be simplified with match, but tests should first document coercion and fall-through behavior.

Match Versus An Array Lookup

A constant data mapping may be clearer as an array:

PHP example
<?php

declare(strict_types=1);

const COUNTRY_NAMES = [
    'GB' => 'United Kingdom',
    'FR' => 'France',
    'DE' => 'Germany',
];

Use an array when the relationship is data, needs iteration, or may be configured. Use match when each arm can return a different expression, strict subject handling matters, or exhaustiveness should be enforced.

Do not turn a large configuration catalogue into hundreds of match arms. Store data as data.

Match Versus Polymorphism

Repeated matching on object type or status can indicate missing behavior:

match payment type to authorize
match payment type to capture
match payment type to refund

A PaymentGateway interface with separate implementations may keep related behavior together and allow dependency injection. The Strategy Pattern lesson develops this approach.

One small boundary mapping does not require a class hierarchy. Introduce polymorphism when it removes repeated branching and gives each variation meaningful behavior.

Keep Side Effects Outside Value Mapping

A match used only to choose and return a value is easy to test. A match whose arms send email, update a database, log, and mutate state becomes difficult to reason about.

Choose the operation, then execute it through a named boundary:

PHP example
<?php

declare(strict_types=1);

function handlerName(string $eventType): string
{
    return match ($eventType) {
        'OrderPlaced' => 'send-order-confirmation',
        'OrderCancelled' => 'release-stock',
        default => throw new InvalidArgumentException('Unknown event type.'),
    };
}

In larger systems, a handler map or service container is usually better than a central match that knows every service.

Refactor Carefully

When replacing switch or if logic, test:

  • string versus integer inputs;
  • null, false, and empty strings;
  • unknown values;
  • missing break behavior in the old switch;
  • side effects and evaluation order;
  • whether a default hides a new closed-set case;
  • exception type and message;
  • return type consistency.

A refactor from loose switch comparison to strict match can intentionally fix a bug, but it is still a behavior change and should be reviewed as one.

Compatibility

match requires PHP 8.0 or later. It is language syntax and cannot be polyfilled for an older parser. A package supporting PHP 7.4 cannot contain match in files that PHP 7.4 may parse.

Use Composer's PHP platform constraint, CI across supported versions, and static compatibility checks. For an older baseline, use if, switch, a lookup array, or polymorphism.

Do not copy a modern syntax example into a project without checking its deployed PHP version.

Testing Match Expressions

Use table-driven tests for every arm and boundary. For match (true) ranges, test values immediately below, at, and above each threshold.

For closed enums, test every case. Mutation testing can reveal an untested arm or boundary. Test the expected exception for unsupported open input.

Keep the function pure when possible: one input, one returned value. This makes complete coverage straightforward.

Review Checklist

When reviewing a match, ask:

  • Is PHP 8.0 or later guaranteed?
  • Is strict comparison intended?
  • Is the subject already parsed to the correct type?
  • Is default appropriate for an open set, or hiding a missing closed case?
  • Are grouped conditions genuinely equivalent?
  • Does match (true) order ranges correctly?
  • Are arm expressions free from hidden workflows?
  • Would data, a function map, or polymorphism be clearer?
  • Are every arm and boundary tested?

What You Should Be Able To Do

After this lesson, you should be able to return values from match, explain strict identity and exhaustiveness, group conditions, throw from a default arm, and use match (true) for short ordered predicates.

You should also be able to choose between match, switch, arrays, and polymorphism, identify compatibility constraints, and refactor older branching without silently changing coercion, fall-through, or unknown-value behavior.

Practice

Map Order Status

Write a strict-types function that maps pending, paid, dispatched, and cancelled to customer-facing labels. Unknown external strings must throw InvalidArgumentException.

Call every valid arm and the invalid path. Explain why strict comparison and no fall-through are useful here.

Show solution
PHP example
<?php

declare(strict_types=1);

function orderLabel(string $status): string
{
    return match ($status) {
        'pending' => 'Waiting for payment',
        'paid' => 'Preparing order',
        'dispatched' => 'On the way',
        'cancelled' => 'Order cancelled',
        default => throw new InvalidArgumentException('Unknown order status.'),
    };
}

Strict comparison prevents coercion, and each selected arm returns one result without requiring break.

Classify A Range

Use match (true) to classify parcel weight as empty, small up to 500 grams, medium up to 2,000 grams, and large above that. Reject negative input before the match.

Test -1, 0, 500, 501, 2000, and 2001 and explain why condition order matters.

Show solution
PHP example
<?php

declare(strict_types=1);

function parcelBand(int $grams): string
{
    if ($grams < 0) {
        throw new InvalidArgumentException('Weight cannot be negative.');
    }

    return match (true) {
        $grams === 0 => 'empty',
        $grams <= 500 => 'small',
        $grams <= 2_000 => 'medium',
        default => 'large',
    };
}

The first true arm wins, so lower and narrower ranges must precede broader ranges.

Fix An Unhandled Match

A function matches an enum with Pending and Paid cases. A Failed case is added later and production throws UnhandledMatchError.

Decide whether to add an explicit Failed arm or a default. Explain the decision for a closed enum, update the code, and describe the test that should have caught it.

Show solution

A parameterized test should iterate every enum case and assert the expected message. Static analysis may also report an unhandled enum case, but the behavior test remains useful.