Modern Control Flow, String and Array Improvements, and Deprecations
Go deeper on match expressions and how they compare to switch, learn the newer string/array functions PHP has added in recent versions, and build a practical mental model for reading version-specific feature notes and handling deprecations without breaking production code.
PHP's control-flow and standard-library surface has evolved steadily across 8.0 through 8.3 — match expressions replaced many switch statements, str_contains()/str_starts_with()/str_ends_with() finally gave PHP readable string-search primitives, array_is_list() answered a question developers had been hand-rolling for years, and readonly properties changed how deprecation warnings show up in day-to-day code. This lesson treats these not as a scattered feature list but as a coherent shift toward more explicit, less error-prone everyday code — and gives you a repeatable way to evaluate any 'new in PHP X.Y' note you encounter.
match expressions in depth: strict comparison and exhaustiveness
match (PHP 8.0) is an expression, not a statement — it evaluates to a value, which switch never does. Its comparisons use strict equality (===), unlike switch's loose (==) comparison, which eliminates a long-standing class of bugs where switch ($x) { case 0: ... } would accidentally match the string "abc" because "abc" == 0 is true under loose comparison in older PHP. match also has no fallthrough between arms — there is no break to forget — and each arm can list multiple comma-separated conditions that share one result: match($x) { 1, 2, 3 => 'low', default => 'high' }.
Critically, match is exhaustive by construction: if no arm matches and there is no default, PHP throws an UnhandledMatchError at runtime instead of silently doing nothing the way an unmatched switch would. This makes match an excellent enforcement mechanism for enum-based branching — if you add a new case to a backed enum and forget to add a corresponding match arm elsewhere in the codebase, that call site throws instead of quietly falling through, surfacing the gap immediately in testing rather than as a silent logic bug in production.
The trade-off is that match arms are single expressions, not statement blocks — you cannot write multiple statements or loops directly inside an arm the way you can in a switch case (though an arm can call a function that does). Use match when you're selecting and returning/assigning a value based on a fixed set of conditions; keep switch (or if/elseif) for branches that need to run several statements or have complex fallthrough behavior.
• match uses strict (===) comparison; switch uses loose (==) comparison. • match is an expression — it returns a value; switch is a statement — it does not. • No fallthrough in match, and no break needed; comma-separated conditions share one arm. • An unmatched match with no default throws UnhandledMatchError instead of silently continuing.
Pairing match with backed enums for exhaustive, type-safe branching
Enums (PHP 8.1) and match are designed to work together. A backed enum defines a fixed, closed set of possible values, and match-ing over every case of that enum gives you compile-time-adjacent confidence (enforced by static analyzers like PHPStan, and at minimum runtime-enforced by UnhandledMatchError) that every case is handled. This pattern — sometimes informally called an 'enum switch' — replaces the older convention of loosely-typed string or integer constants compared with switch, where adding a new constant silently produced no compiler or analyzer warning anywhere it should have been handled.
The practical workflow is: define the enum, write match ($status) { Status::Pending => ..., Status::Shipped => ..., Status::Cancelled => ... } with no default, and let both your static analyzer and the runtime UnhandledMatchError act as a safety net whenever the enum grows a new case that some match block forgot to account for.
Newer string functions: readable, unambiguous substring checks
PHP 8.0 added str_contains(), str_starts_with(), and str_ends_with() — direct, boolean-returning replacements for the historically awkward strpos($haystack, $needle) !== false pattern. Before these existed, strpos($haystack, $needle) returning 0 (a match at the very start of the string) was famously indistinguishable from false (no match at all) under loose comparison, and using strpos(...) !== false correctly was a common source of subtle bugs for beginners who wrote strpos(...) == false or just strpos(...) by mistake. The new functions simply return true/false and can never be confused with a numeric offset.
str_contains($haystack, $needle), str_starts_with($haystack, $needle), and str_ends_with($haystack, $needle) are now the idiomatic way to write these checks, and linters/static analyzers in modern PHP codebases typically flag the old strpos() !== false pattern as a style issue to migrate.
• str_contains('Hello World', 'World') // true — no more strpos() !== false • str_starts_with($path, '/api/') // true/false, unambiguous • str_ends_with($filename, '.php') // true/false, unambiguous
Newer array functions: array_is_list and friends
array_is_list() (PHP 8.1) answers a question that previously required manual, easy-to-get-wrong code: is this array a genuine list — sequential integer keys starting at 0, with no gaps ([0, 1, 2, ...]) — as opposed to an associative array or an array with holes from unset()? This distinction matters because PHP arrays are simultaneously ordered maps and lists under the hood, and code that assumes list semantics (like appending with $arr[] = $x and expecting predictable numeric indices) can silently misbehave on an array that looks list-like but isn't, especially after filtering with array_filter(), which preserves original keys and can leave gaps.
array_filter($arr) followed by code that expects a clean list is a very common bug source; the fix is usually array_values(array_filter($arr)) to reindex, and array_is_list() gives you a precise, named check to assert or guard against exactly this class of mistake, rather than a hand-rolled loop comparing array_keys($arr) against range(0, count($arr) - 1).
• array_is_list([1,2,3]) // true — sequential 0-indexed keys • array_is_list(['a' => 1, 'b' => 2]) // false — associative keys • array_is_list(array_filter([0,1,2,3])) // false — array_filter can leave gaps in the keys • array_values(array_filter($arr)) reindexes back into a true list after filtering.
Reading version-specific feature notes and thinking about deprecations
PHP's manual and changelogs label features with 'Added in PHP X.Y' and mark obsolete behavior with 'Deprecated as of PHP X.Y' — treat both as load-bearing information, not footnotes. A feature added in 8.1 will fatal-error on PHP 8.0 (it's not a warning — the parser doesn't recognize the syntax at all), so before using enums, readonly properties, never, first-class callable syntax, or fibers, check your deployment target's actual PHP version, not just 'PHP 8'. Composer's "require": {"php": "^8.1"} constraint in composer.json is the practical mechanism for declaring and enforcing a minimum version across a team.
A deprecation notice (E_DEPRECATED) means the feature still works today but is scheduled for removal in a future major version — it is a warning to migrate proactively, not an immediate break. PHP's historical pattern is: deprecate in one minor version, keep working (with a notice) for the rest of that major version's lifetime, then remove entirely in the next major version. For example, dynamic properties on non-#[AllowDynamicProperties] classes were deprecated in PHP 8.2 and became a fatal error only in the sense that the deprecation notice is emitted every time — they still function in 8.2/8.3, but code relying on them should be migrated (typed properties, or explicit #[AllowDynamicProperties]) before they become a hard error in a later major version.
The practical discipline: run your test suite with error_reporting(E_ALL) including deprecation notices turned on (rather than suppressed) in CI, treat each E_DEPRECATED as a small ticket to fix before upgrading, and never assume 'it still runs without error' means 'it will keep running on the next major version.'
• 'Added in PHP X.Y' = using it on an older version is a hard parse/fatal error, not a graceful fallback. • 'Deprecated as of PHP X.Y' = still works now, emits E_DEPRECATED, and is planned for removal later. • Pin a minimum PHP version in composer.json ("php": "^8.2") to make version assumptions explicit and enforced. • Keep deprecation notices visible in CI/logs rather than suppressed — they are an early-warning system, not noise.
match, enums, and the newer string/array helpers together
match's UnhandledMatchError is the enforcement mechanism that makes it a good partner for exhaustive enum handling. str_contains/str_starts_with/str_ends_with always return true/false — never confuse them with strpos's mixed int|false return. array_is_list() is the precise, named replacement for hand-rolled 'is this a sequential array' checks.
enum OrderStatus: string
{
case Pending = 'pending';
case Shipped = 'shipped';
case Cancelled = 'cancelled';
}
function describeStatus(OrderStatus $status): string
{
return match ($status) {
OrderStatus::Pending => 'Waiting to ship',
OrderStatus::Shipped => 'On its way',
OrderStatus::Cancelled => 'Order cancelled',
// no default: adding a new case without updating this match throws UnhandledMatchError
};
}
function isApiPath(string $path): bool
{
return str_starts_with($path, '/api/') && !str_ends_with($path, '.php');
}
$filtered = array_filter([0, 1, 2, 3, 4], fn ($n) => $n % 2 === 0);
var_dump(array_is_list($filtered)); // false — keys have gaps
var_dump(array_is_list(array_values($filtered))); // true — reindexedCode Examples
switch compares with loose equality, so historically 'abc' == 0 could evaluate true in PHP's older coercion rules for non-numeric strings, misrouting into the wrong case. match, using strict === comparison throughout (here via the match (true) { condition => result } idiom for non-enum conditions), never confuses a string with an integer, so it reliably falls through to default. Expected result: other (switch) other (match) return match (true) { $value === 0 => 'zero (match)', default => 'other (match)' };
Because the match block omits a default and forgets the West case, calling opposite(Direction::West) throws UnhandledMatchError at runtime rather than silently returning null or falling through — exactly the safety net that makes match well suited to enum-driven branching. A switch with no matching case and no default would instead have silently done nothing. Expected result: Caught: Unhandled match case Direction::West return match ($d) { Direction::North => Direction::South,... }; // West case missing
All four checks agree here, but str_contains() can never be mistyped into the classic strpos() == false bug (which fails when the match is at position 0), because it always returns a real boolean rather than an integer-or-false union. Expected result: bool(true) bool(true) bool(true) bool(true)
function classifySwitch($value): string
{
switch ($value) {
case 0:
return 'zero (switch)';
default:
return 'other (switch)';
}
}
function classifyMatch($value): string
{
return match (true) {
$value === 0 => 'zero (match)',
default => 'other (match)',
};
}
echo classifySwitch('abc') . PHP_EOL; // loose == lets 'abc' match case 0 on old PHP-style comparisons
echo classifyMatch('abc') . PHP_EOL; // strict === correctly falls to defaultenum Direction
{
case North;
case South;
case East;
case West;
}
function opposite(Direction $d): Direction
{
return match ($d) {
Direction::North => Direction::South,
Direction::South => Direction::North,
Direction::East => Direction::West,
// West case intentionally 'forgotten' to demonstrate enforcement
};
}
try {
opposite(Direction::West);
} catch (\UnhandledMatchError $e) {
echo 'Caught: ' . $e->getMessage() . PHP_EOL;
}$log = 'ERROR: connection to database refused';
// old, error-prone idiom
$isErrorOld = strpos($log, 'ERROR') !== false;
// modern, unambiguous idiom
$isErrorNew = str_contains($log, 'ERROR');
$startsWithLevel = str_starts_with($log, 'ERROR:');
$endsWithRefused = str_ends_with($log, 'refused');
var_dump($isErrorOld, $isErrorNew, $startsWithLevel, $endsWithRefused);match's evaluation model vs switch
match trades switch's flexibility (statement blocks, fallthrough) for safety (strict comparison, mandatory exhaustiveness) — pick the one whose trade-off fits the branch you're writing.
switch ($x) { match ($x) {
case A: ...; break; A => valueA,
case B: ...; break; B, C => valueBC, // multi-condition arm
default: ...; default => valueD,
} };
- statement - EXPRESSION (produces a value)
- loose == comparison - strict === comparison
- falls through w/o break - no fallthrough, ever
- silent no-op if unmatched - throws UnhandledMatchError if unmatched (no default)Important Rules
Reach for match when you are producing/returning a value from a fixed set of conditions; keep switch for multi-statement branches.
Pair match with backed enums and omit default when you want new enum cases to force a compile-adjacent review of every match site.
Replace strpos($h, $n) !== false with str_contains($h, $n) (and the starts/ends_with variants) in all new code.
Reindex with array_values() after array_filter() before code that assumes list semantics; use array_is_list() to assert it.
Treat E_DEPRECATED notices as a migration to-do list, not noise to suppress — check composer.json's PHP version constraint before using any 'Added in PHP X.Y' feature.
Where this shows up
Framework routers and request validators lean on match with backed enums (HTTP methods, permission levels, order statuses) precisely because UnhandledMatchError turns a missed case into a loud failure in tests instead of a silent bug in production; and the str_*_with()/array_is_list() functions show up constantly in request-path routing, file-extension checks, and API payload normalization — places where the old strpos()/array_keys() idioms were a recurring source of off-by-one and falsy-zero bugs.
if (strpos($haystack, $needle)) { /* 'found' */ } if (str_contains($haystack, $needle)) { /* found */ }
strpos() returns the integer position of the match, or false. If the match is at position 0 (the very start of the string), if (strpos(...)) treats 0 as falsy and incorrectly reports 'not found.' str_contains() always returns a genuine boolean, eliminating the bug entirely.
$evens = array_filter($nums, fn($n) => $n % 2 === 0); $first = $evens[0]; // may be undefined if 0 was filtered out
$evens = array_values(array_filter($nums, fn($n) => $n % 2 === 0)); $first = $evens[0] ?? null;
array_filter() preserves original keys, so after filtering, index 0 may no longer exist even though the result has elements — accessing $evens[0] directly can throw an undefined-array-key warning. array_values() reindexes into a genuine 0-based list first.
What is the key difference in comparison behavior between switch and match? A. switch uses strict === comparison; match uses loose == comparison B. match uses strict === comparison; switch uses loose == comparison C. Both use strict === comparison D. Both use loose == comparison
B. match uses strict === comparison; switch uses loose == comparison — match always compares with strict === equality, avoiding the type-coercion surprises that loose == comparison in switch can produce.
What happens when a match expression finds no matching arm and has no default? A. It silently returns null B. It falls through to the next statement with no error C. It throws an UnhandledMatchError D. It triggers a PHP deprecation notice only
C. It throws an UnhandledMatchError — An unmatched match with no default arm throws UnhandledMatchError at runtime — this is what makes match reliable for enforcing exhaustive handling of enum cases.
Why is str_contains($haystack, $needle) preferred over strpos($haystack, $needle) !== false? A. str_contains is faster in every PHP version B. strpos() can return 0 for a match at the start of the string, which is falsy and easy to mishandle with loose checks; str_contains() always returns a real boolean C. strpos() was removed in PHP 8 D. There is no practical difference
B. strpos() can return 0 for a match at the start of the string, which is falsy and easy to mishandle with loose checks; str_contains() always returns a real boolean — strpos() returns an int|false union where a match at position 0 is falsy, a classic source of bugs with careless (strpos(...)) checks; str_contains() removes the ambiguity by always returning a genuine boolean.
What does array_is_list() check for? A. Whether an array is non-empty B. Whether every value in the array is the same type C. Whether the array has sequential integer keys starting at 0 with no gaps D. Whether the array was created with array_filter()
C. Whether the array has sequential integer keys starting at 0 with no gaps — array_is_list() returns true only for arrays with strictly sequential 0-indexed integer keys — exactly the property array_filter() can break, since it preserves original keys and can leave gaps.
What does an E_DEPRECATED notice mean about a PHP feature? A. The feature has already been removed and the code will fatal-error B. The feature still works now but is planned for removal in a future major version, and should be migrated away from proactively C. The feature is brand new and experimental D. The notice can always be safely ignored permanently
B. The feature still works now but is planned for removal in a future major version, and should be migrated away from proactively — A deprecation notice signals that current behavior still functions but is scheduled for removal in a later major version — it is an early warning to migrate, not an immediate break, and should not be permanently suppressed.
Define a backed enum HttpMethod with cases Get, Post, Put, Delete (string-backed with their uppercase HTTP verb values). Write a function requiresBody(HttpMethod $method): bool using an exhaustive match with no default. Then write a function isJsonApiPath(string $path): bool that returns true only if $path starts with '/api/' and ends with '.json', using str_starts_with()/str_ends_with().
enum HttpMethod: string { case Get = 'GET'; case Post = 'POST'; case Put = 'PUT'; case Delete = 'DELETE'; } function requiresBody(HttpMethod $method): bool { return match ($method) { HttpMethod::Post, HttpMethod::Put => true, HttpMethod::Get, HttpMethod::Delete => false, }; } function isJsonApiPath(string $path): bool { return str_starts_with($path, '/api/') && str_ends_with($path, '.json'); }
Post and Put should return true from requiresBody(); Get and Delete should return false. Combine the two str_*_with() checks with &&.