Control Flow, Functions and Closures
PHP's control flow is unremarkable except in three places: match is not switch, function signatures carry real types that are enforced at run time, and closures capture by value unless you say otherwise. Those three are where the bugs are.
The loops
<?php
declare(strict_types=1);
$rows = ['a' => 1, 'b' => 2, 'c' => 3];
foreach ($rows as $value) {
echo $value;
}
echo PHP_EOL;
foreach ($rows as $key => $value) {
echo "$key=$value ";
}
echo PHP_EOL;
for ($i = 0; $i < 3; $i++) {
echo $i;
}
echo PHP_EOL;
$i = 0;
while ($i < 3) {
echo $i;
$i++;
}
echo PHP_EOL;
do {
echo 'once';
} while (false);
echo PHP_EOL;
Open this example in the editor and change it. Nothing you type is saved.
foreach is the one you want almost always. It works on arrays, on any Traversable, and on a generator - none of which a for loop with an index can do.
continue and break take a level, which is the readable alternative to a flag variable:
<?php
declare(strict_types=1);
$grid = [[1, 2, 3], [4, -1, 6], [7, 8, 9]];
foreach ($grid as $rowIndex => $row) {
foreach ($row as $columnIndex => $cell) {
if ($cell < 0) {
echo "negative at $rowIndex,$columnIndex", PHP_EOL;
break 2; // leaves BOTH loops
}
}
}
Open this example in the editor and change it. Nothing you type is saved.
break 2 and continue 2 are PHP-specific and genuinely useful. Note that inside a switch, continue behaves like break - PHP counts switch as a loop level, which is a trap, and continue 2 is what you want to reach the enclosing loop.
match, which is not switch
<?php
declare(strict_types=1);
$code = 404;
// switch: loose comparison, falls through, no value.
switch ($code) {
case 404:
$label = 'Not Found';
break; // forget this and you get the next one
default:
$label = 'Unknown';
}
// match: strict comparison, no fallthrough, IS an expression.
$label = match ($code) {
200, 201, 204 => 'Success',
301, 302 => 'Redirect',
404 => 'Not Found',
default => 'Unknown',
};
echo $label, PHP_EOL; // Not Found
Open this example in the editor and change it. Nothing you type is saved.
Four differences, and every one of them is in match's favour:
<?php
declare(strict_types=1);
// 1. Strict comparison: "1" does not match 1.
$value = "1";
echo match (true) {
$value === 1 => 'int one',
$value === "1" => 'string one',
default => 'neither',
}, PHP_EOL; // string one
// 2. No fallthrough. There is no `break` and none is needed.
// 3. It is an expression, so it returns a value and can be assigned,
// returned or passed as an argument.
function statusClass(int $code): string
{
return match (true) {
$code >= 500 => 'server-error',
$code >= 400 => 'client-error',
$code >= 300 => 'redirect',
$code >= 200 => 'success',
default => 'informational',
};
}
echo statusClass(503), ' ', statusClass(201), PHP_EOL;
// 4. An unmatched value throws rather than silently doing nothing.
try {
echo match (999) { 200 => 'ok' };
} catch (UnhandledMatchError $e) {
echo get_class($e), PHP_EOL; // UnhandledMatchError
}
Open this example in the editor and change it. Nothing you type is saved.
match (true) with conditions on the left is the idiom for ranges, and it reads better than the if/elseif chain it replaces because every branch is visibly one expression.
The one thing switch still does that match cannot: run several statements per branch. If a branch needs a body rather than a value, either extract a function or use if.
Functions, types and defaults
<?php
declare(strict_types=1);
function connect(
string $host,
int $port = 3306,
?string $user = null,
bool $tls = false,
): string {
return $host . ':' . $port
. ($user !== null ? " as $user" : '')
. ($tls ? ' (tls)' : '');
}
echo connect('db.local'), PHP_EOL;
echo connect('db.local', 5432, 'app'), PHP_EOL;
// Named arguments: skip the middle, and say what the boolean means.
echo connect(host: 'db.local', tls: true), PHP_EOL;
echo connect('db.local', user: 'app', tls: true), PHP_EOL;
Open this example in the editor and change it. Nothing you type is saved.
Named arguments are the fix for the unreadable boolean call. connect($host, 5432, null, true) tells a reader nothing; connect($host, tls: true) tells them everything. They also mean a parameter can be added in the middle of a signature without breaking callers who used names.
Variadics and spreading:
<?php
declare(strict_types=1);
function tally(string $label, int ...$amounts): string
{
return $label . ': ' . array_sum($amounts) . ' from ' . count($amounts);
}
echo tally('total', 1, 2, 3), PHP_EOL; // total: 6 from 3
$values = [4, 5, 6];
echo tally('spread', ...$values), PHP_EOL; // spread: 15 from 3
$named = ['label' => 'named', 'amounts' => 7];
echo tally(...['label' => 'from keys']), PHP_EOL; // string keys become names
Open this example in the editor and change it. Nothing you type is saved.
Union types, nullable types and never:
<?php
declare(strict_types=1);
function parse(string|int $input): int|float
{
return is_int($input) ? $input : (float) $input;
}
function first(?array $values): mixed
{
return $values[0] ?? null;
}
function fail(string $message): never
{
throw new RuntimeException($message);
}
var_dump(parse(3), parse('3.5'));
var_dump(first(null), first([1, 2]));
try {
fail('stopped');
} catch (RuntimeException $e) {
echo $e->getMessage(), PHP_EOL;
}
Open this example in the editor and change it. Nothing you type is saved.
never tells both the reader and static analysis that the function does not return, so the code after a call to it is unreachable - which means no "undefined variable" warning for a branch that cannot happen.
Closures and arrow functions
<?php
declare(strict_types=1);
$factor = 3;
// A closure captures by value, and only what you list.
$multiply = function (int $n) use ($factor): int {
return $n * $factor;
};
// An arrow function captures automatically, by value, and is one
// expression only.
$multiplyShort = fn (int $n): int => $n * $factor;
echo $multiply(5), ' ', $multiplyShort(5), PHP_EOL; // 15 15
$factor = 10;
echo $multiply(5), ' ', $multiplyShort(5), PHP_EOL; // 15 15 - captured
Open this example in the editor and change it. Nothing you type is saved.
Both captured $factor when they were defined. Changing it afterwards changes nothing, because the capture was by value.
To capture by reference, say so - and then the later change does apply:
<?php
declare(strict_types=1);
$count = 0;
$increment = function () use (&$count): int {
return ++$count;
};
echo $increment(), $increment(), $increment(), PHP_EOL; // 123
echo $count, PHP_EOL; // 3
Open this example in the editor and change it. Nothing you type is saved.
An arrow function cannot capture by reference, which is a deliberate limitation: fn is for short pure expressions, and a function with an explicit use is for anything with state.
The practical difference in a callback:
<?php
declare(strict_types=1);
$rows = [['n' => 3], ['n' => 1], ['n' => 2]];
$key = 'n';
usort($rows, fn (array $a, array $b): int => $a[$key] <=> $b[$key]);
echo implode(',', array_column($rows, 'n')), PHP_EOL; // 1,2,3
// The same with a closure needs the `use`, which is why `fn` exists.
usort($rows, function (array $a, array $b) use ($key): int {
return $b[$key] <=> $a[$key];
});
echo implode(',', array_column($rows, 'n')), PHP_EOL; // 3,2,1
Open this example in the editor and change it. Nothing you type is saved.
First-class callable syntax, since PHP 8.1, replaces the string and array forms:
<?php
declare(strict_types=1);
$numbers = ['3', '1', '2'];
print_r(array_map(intval(...), $numbers)); // first-class callable
print_r(array_map('intval', $numbers)); // the old string form
final class Formatter
{
public function __construct(private readonly int $decimals) {}
public function format(float $value): string
{
return number_format($value, $this->decimals);
}
}
$formatter = new Formatter(2);
print_r(array_map($formatter->format(...), [1.5, 2.25]));
Open this example in the editor and change it. Nothing you type is saved.
intval(...) is a real Closure the editor can check, unlike 'intval', which is a string nobody validates until it runs.
A worked example
<?php
declare(strict_types=1);
/**
* A tiny rules engine: match for the decision, closures for the rules,
* named arguments for the configuration.
*/
function classify(
int $statusCode,
float $seconds,
bool $authenticated = false,
int $slowThresholdMs = 500,
): array {
$severity = match (true) {
$statusCode >= 500 => 'error',
$statusCode === 429 => 'warn',
$statusCode >= 400 && $authenticated => 'warn',
$statusCode >= 400 => 'info',
$seconds * 1000 > $slowThresholdMs => 'warn',
default => 'debug',
};
$reason = match ($severity) {
'error' => 'server failure',
'warn' => $statusCode === 429 ? 'rate limited'
: ($statusCode >= 400 ? 'authenticated client error' : 'slow response'),
'info' => 'client error',
default => 'normal',
};
return ['severity' => $severity, 'reason' => $reason];
}
$requests = [
['code' => 200, 'seconds' => 0.031],
['code' => 200, 'seconds' => 0.902],
['code' => 404, 'seconds' => 0.012],
['code' => 404, 'seconds' => 0.012, 'auth' => true],
['code' => 429, 'seconds' => 0.005],
['code' => 503, 'seconds' => 1.902],
];
$counts = [];
foreach ($requests as $request) {
$result = classify(
statusCode: $request['code'],
seconds: $request['seconds'],
authenticated: $request['auth'] ?? false,
);
$counts[$result['severity']] ??= 0;
$counts[$result['severity']]++;
printf("%3d %6.3fs %-5s %s%s",
$request['code'], $request['seconds'],
$result['severity'], $result['reason'], PHP_EOL);
}
echo PHP_EOL;
ksort($counts);
foreach ($counts as $severity => $count) {
printf("%-6s %d%s", $severity, $count, PHP_EOL);
}
// Closures as composable rules, built once and applied to every row.
$rules = [
'slow' => fn (array $r): bool => $r['seconds'] > 0.5,
'failed' => fn (array $r): bool => $r['code'] >= 500,
'notFound' => fn (array $r): bool => $r['code'] === 404,
];
echo PHP_EOL;
foreach ($rules as $name => $rule) {
$matching = array_values(array_filter($requests, $rule));
printf("%-9s %d request(s): %s%s",
$name, count($matching),
implode(', ', array_map(fn (array $r): string => (string) $r['code'], $matching)),
PHP_EOL);
}
// And a counter that must accumulate, which needs a reference.
$total = 0;
$add = function (array $r) use (&$total): void {
$total += (int) round($r['seconds'] * 1000);
};
array_walk($requests, $add);
printf("%sTotal %dms across %d request(s)%s", PHP_EOL, $total, count($requests), PHP_EOL);
Open this example in the editor and change it. Nothing you type is saved.
Three things are doing work there. match (true) turns a five-branch decision into five readable lines with no break to forget. Named arguments at the call site mean authenticated: false rather than a bare false nobody can interpret. And the one place state must accumulate - $total - uses use (&$total) explicitly, so the mutation is visible rather than implied.
When it goes wrong
| Symptom | Cause |
|---|---|
A switch ran two branches |
A missing break; use match |
"1" matched case 1 |
switch compares loosely; match does not |
UnhandledMatchError in production |
No default arm; add one or handle the case |
continue inside switch acted like break |
PHP counts switch as a level; use continue 2 |
| A closure saw a stale value | Capture is by value at definition time |
| An arrow function could not accumulate | fn cannot capture by reference; use function |
| A boolean argument's meaning is unreadable | Use named arguments |
'methodName' as a callback failed silently |
Use the first-class callable syntax |
A check you can run
<?php
foreach ([1, 2, 3] as $n) {
switch ($n) {
case 2:
continue 2;
}
echo $n;
}
echo PHP_EOL;
Open this example in the editor and change it. Nothing you type is saved.
It prints 13. Now change continue 2 to plain continue and run it again: it prints 123, because continue inside a switch only leaves the switch, not the loop.
One digit of difference, and a filter that silently stops filtering. It is the clearest argument there is for using match and never putting a switch inside a loop.