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
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
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
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
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
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
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
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
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
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
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
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
breakbehavior 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
defaultappropriate 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
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
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.