PHP Control Flow: Branching, Looping, and Guard Clauses

Master PHP's full set of branching and looping constructs — if/elseif/else, switch, match, for, while, do-while, foreach — along with break, continue, early returns, and the guard-clause style that keeps real-world functions readable.

Control flow is what turns a flat list of statements into a program that makes decisions and repeats work. PHP offers several branching constructs that overlap in purpose but differ in important ways — switch's loose comparison versus match's strict comparison being the sharpest example — and several looping constructs suited to different situations. Writing clean control flow is less about knowing the syntax (which is simple) and more about choosing the right construct and structuring conditions — especially guard clauses — so the resulting function stays flat and readable instead of nesting five levels deep.

if / elseif / else and switch

The if/elseif/else chain is PHP's general-purpose branching tool: it evaluates each condition in order and executes the first block whose condition is truthy, falling through to else if none match. Conditions are evaluated top to bottom and short-circuit — once one is truthy, later elseif conditions are never even evaluated, which matters if a later condition has a side effect (a good reason to avoid side effects in conditions at all).

switch compares one value against several possible cases using loose (==-style) comparison, which is a frequent source of bugs: switch ('abc') { case 0: ... } would actually have matched in old PHP versions because of numeric string coercion (this exact case was fixed in PHP 8's comparison rules, but switch/case comparisons generally still use loose semantics, so it's easy to get surprising matches with mixed types). Each case block needs an explicit break; or it falls through into the next case's code — a deliberate PHP feature (useful for grouping multiple cases that should run the same code) but also a classic bug source when a break is accidentally omitted.

Because of both the loose-comparison surprises and the fall-through footgun, modern PHP code increasingly reaches for match (PHP 8.0+) instead of switch whenever a value needs to select between several fixed expressions.

• if ($score >= 90) { $grade = 'A'; } elseif ($score >= 80) { $grade = 'B'; } else { $grade = 'C'; } • switch ($status) { case 'paid': case 'shipped': echo 'processed'; break; default: echo 'pending'; } • Forgetting break; in a switch case causes execution to fall through into the next case's code. • switch uses loose (==) comparison; be cautious mixing types in case values.

match (PHP 8+)

match is an expression (it returns a value directly, unlike switch, which is a statement) that compares using strict (===) comparison by default, has no fall-through between arms (no break needed, and none possible), and throws an UnhandledMatchError if no arm matches and there's no default — all three of these differences make match meaningfully safer than switch for the common case of 'pick one of several values based on this input'.

A single match arm can list multiple comma-separated conditions that map to the same result: match ($day) { 1, 2, 3, 4, 5 => 'Weekday', 6, 7 => 'Weekend' };. Because it's an expression, you assign its result directly instead of assigning inside each branch: $label = match (true) { $score >= 90 => 'A', $score >= 80 => 'B', default => 'C' }; — the match (true) idiom is a common and powerful pattern for range-based matching, since each arm is itself a boolean condition compared against true.

The strict comparison also avoids switch's type-coercion surprises entirely: match ('0') { 0 => 'int zero', '0' => 'string zero' } correctly returns 'string zero' because === distinguishes the types, whereas the equivalent switch would have matched the first case 0 due to loose comparison.

• $label = match ($httpCode) { 200, 201, 204 => 'Success', 404 => 'Not Found', default => 'Error' }; • match (true) { $age < 13 => 'child', $age < 20 => 'teen', default => 'adult' }; — range-matching idiom. • match throws UnhandledMatchError if nothing matches and there's no default — switch silently does nothing instead. • match uses === (strict); switch uses == (loose) — this is the single biggest practical difference.

for, while, do-while, and foreach

for (init; condition; increment) { } is best when you know in advance how the iteration should progress — typically a numeric counter: for ($i = 0; $i < count($items); $i++). while (condition) { } checks its condition before each iteration (including the first), suiting loops that run zero or more times based on some evolving state, like reading lines from a file until EOF. do { } while (condition); is while's counterpart that checks the condition *after* the body runs, guaranteeing at least one execution — useful for things like 'retry this operation, then check if it succeeded, repeat if not.'

foreach ($array as $value) { } and foreach ($array as $key => $value) { } are the idiomatic way to iterate PHP arrays (and any object implementing Iterator/Traversable, including generators) — they're clearer and less error-prone than a manual for loop over array indices, and they naturally respect the array's actual insertion order and keys regardless of what those keys are. foreach ($array as &$value) { ... } unset($value); iterates by reference to mutate elements in place — but you must unset($value) afterward, because otherwise $value remains a reference to the array's last element and can cause very confusing bugs if it's accidentally reused in a later loop.

All PHP loops support break (exit the loop entirely) and continue (skip to the next iteration), and both accept an optional numeric argument — break 2; or continue 2; — to affect an outer loop when loops are nested, avoiding the need for a manual flag variable to escape multiple levels at once.

• for ($i = 0; $i < 10; $i++) { ... } — classic counted loop. • while ($row = $stmt->fetch()) { ... } — loop driven by an evolving external condition. • do { $attempt++; } while (!$success && $attempt < 3); — body guaranteed to run at least once. • foreach ($matrix as $row) { foreach ($row as $cell) { if ($cell < 0) continue 2; } } — continue 2 skips to the next outer row. • foreach ($items as &$item) { $item *= 2; } unset($item); — always unset() a by-reference foreach variable afterward.

Guard Clauses and Early Returns

A guard clause is an early return (or throw, continue, break) placed at the top of a function that immediately handles an invalid or edge-case input, letting the rest of the function assume the 'happy path' without wrapping it in a deeply nested if. Compare function process($order) { if ($order !== null) { if ($order->isValid()) { /* 20 lines of real logic */ } } } against the guard-clause version: function process($order) { if ($order === null) return; if (!$order->isValid()) return; /* 20 lines of real logic, unindented */ } — the second version is flatter, and each precondition is dealt with and dismissed in one line right where a reader first encounters it.

This pattern scales well: a function can have several guard clauses in a row, each handling one specific invalid case and returning (or throwing a specific exception) immediately, so that by the time execution reaches the function's main body, every precondition is already guaranteed to hold — the reader doesn't have to hold multiple nested conditions in their head simultaneously to understand what the core logic does.

The same principle applies inside loops with continue used as a mini guard clause: foreach ($orders as $order) { if ($order->status !== 'paid') continue; /* only paid-order logic here, unindented */ } is preferable to wrapping the entire loop body in if ($order->status === 'paid') { ... }, for exactly the same readability reason.

• function divide(float $a, float $b): float { if ($b === 0.0) { throw new InvalidArgumentException('Division by zero'); } return $a / $b; } • Guard clauses trade nesting depth for a short list of early exits — almost always a readability win in real functions. • continue as a guard inside foreach avoids wrapping the entire loop body in one more level of if-nesting. • Each guard clause should handle exactly one precondition and exit immediately — resist combining several checks into one big condition.

match vs switch, and a guard-clause function

match is generally preferred over switch in modern PHP for simple value-to-value selection: it's strict, expression-based, and has no fall-through footgun. switch is still useful when you deliberately want fall-through behavior across multiple cases running shared code. Stack guard clauses at the top of a function, each with its own single-purpose early return/throw, rather than nesting all preconditions into one large if.

<?php
// switch: loose comparison, fall-through, statement (no return value)
switch ($code) {
    case 200:
    case 201:
        $label = 'OK';
        break;
    default:
        $label = 'Error';
}

// match: strict comparison, no fall-through, is an expression
$label = match ($code) {
    200, 201 => 'OK',
    default => 'Error',
};

// guard clauses keep the happy path unindented
function chargeCard(?Customer $customer, float $amount): Receipt {
    if ($customer === null) {
        throw new InvalidArgumentException('No customer provided');
    }
    if ($amount <= 0) {
        throw new InvalidArgumentException('Amount must be positive');
    }
    if (!$customer->hasValidPaymentMethod()) {
        throw new PaymentException('No valid payment method on file');
    }

    // happy path: every precondition above is already guaranteed here
    return $customer->paymentMethod()->charge($amount);
}

Code Examples

match (true) treats each arm as a boolean condition compared strictly against true, letting you express range-based branching (a series of thresholds) as a clean, ordered list of conditions instead of an if/elseif chain — and because PHP 8.0 also allows throw as an expression, an arm can throw directly without needing its own block. Expected result: $4.99 $9.99 Contact us for a freight quote

$weightKg <= 0 => throw new InvalidArgumentException(...), — throw works as a match arm expression too.

Each guard clause checks exactly one thing and exits immediately with a specific error, so the function reads top-to-bottom as a checklist of requirements followed by the actual work — compare this to nesting all three checks inside one another, which would push the real registration logic three indent levels deep and force a reader to track all three conditions at once to know if it even runs. Expected result: Array ( [ok] => [error] => Email looks invalid )

<?php
function shippingCost(float $weightKg): string {
    return match (true) {
        $weightKg <= 0 => throw new InvalidArgumentException('Weight must be positive'),
        $weightKg <= 1 => '$4.99',
        $weightKg <= 5 => '$9.99',
        $weightKg <= 20 => '$19.99',
        default => 'Contact us for a freight quote',
    };
}

echo shippingCost(0.5) . "\n";
echo shippingCost(3) . "\n";
echo shippingCost(50) . "\n";
<?php
function registerUser(array $input): array {
    if (empty($input['email'])) {
        return ['ok' => false, 'error' => 'Email is required'];
    }
    if (!str_contains($input['email'], '@')) {
        return ['ok' => false, 'error' => 'Email looks invalid'];
    }
    if (empty($input['password']) || strlen($input['password']) < 8) {
        return ['ok' => false, 'error' => 'Password must be at least 8 characters'];
    }

    // happy path — every input is already known-good here
    return ['ok' => true, 'userId' => 12345];
}

print_r(registerUser(['email' => 'bad', 'password' => '12345678']));

Guard clauses vs nested conditionals

Guard clauses turn 'nested preconditions' into 'a short flat list of early exits', keeping the function's main logic unindented and easy to scan.

NESTED (grows rightward with each check):     GUARD CLAUSES (stays flat):
if (A) {                                        if (!A) return;
  if (B) {                                       if (!B) return;
    if (C) {                                     if (!C) return;
      // real logic, 3 levels deep               // real logic, 0 levels deep
    }
  }
}

Important Rules

Prefer match over switch for simple value selection — it's strict (===), has no fall-through, and is an expression you can assign directly.

If you do use switch, never forget break; at the end of each case unless fall-through is deliberate and commented.

Always unset() a by-reference foreach variable (&$value) immediately after the loop to avoid it leaking into later code.

Use guard clauses for precondition checks at the top of a function instead of nesting the whole function body inside if blocks.

Use continue as a guard clause inside loops to skip irrelevant iterations rather than wrapping the loop body in an extra if.

Where this shows up

Input validation in controllers is almost always written as a stack of guard clauses; state-machine-style logic (order statuses, HTTP response codes, user roles) is a natural fit for match; and by-reference foreach with the unset() discipline shows up anywhere an array of objects or rows needs efficient in-place transformation without the overhead of building a whole new array.

switch ($role) { case 'admin': grantFullAccess(); case 'editor': grantEditAccess(); break; } switch ($role) { case 'admin': grantFullAccess(); break; case 'editor': grantEditAccess(); break; }

Without a break after the 'admin' case, execution falls through into the 'editor' case's code too — an admin would incorrectly also run grantEditAccess(). Each case needs its own break unless fall-through is the deliberate, documented intent.

foreach ($items as &$item) { $item = strtoupper($item); } foreach ($items as $item) { echo $item; } // silently corrupts the last element foreach ($items as &$item) { $item = strtoupper($item); } unset($item); foreach ($items as $item) { echo $item; }

After the first foreach, $item remains a reference to $items' last element. The second foreach reuses the variable name $item, and each iteration's assignment through that lingering reference overwrites the array's actual last element — a classic, hard-to-spot PHP bug caused by forgetting unset().

What is the main comparison difference between switch and match? A. They compare identically B. switch uses strict === comparison, match uses loose == C. switch uses loose == comparison, match uses strict === D. Neither performs any type comparison

C. switch uses loose == comparison, match uses strict === — switch compares case values using loose (==) comparison, which can produce surprising matches across types, while match compares using strict (===) comparison by default, avoiding that class of bug entirely.

Why must you call unset($value) after a foreach loop that iterates by reference (foreach ($arr as &$value))? A. It's just a style convention with no functional effect B. Without it, $value keeps referencing the array's last element, which can corrupt data if $value is reused in later code C. PHP requires unset() or it throws a fatal error D. It frees memory that would otherwise leak

B. Without it, $value keeps referencing the array's last element, which can corrupt data if $value is reused in later code — After a by-reference foreach, the loop variable remains a live reference to the array's last element. If that same variable name is reused in a later loop or assignment, it silently writes through to the original array's last element instead of behaving like a fresh variable — unset() breaks that lingering reference.

What is the primary benefit of a guard clause (an early return at the top of a function)? A. It makes the function run faster B. It flattens the function by handling invalid/edge cases immediately, so the main logic isn't nested inside multiple if blocks C. It is required by PHP's type system D. It prevents the function from being called with wrong argument types

B. It flattens the function by handling invalid/edge cases immediately, so the main logic isn't nested inside multiple if blocks — Guard clauses handle one precondition at a time and exit immediately, which keeps the function's main 'happy path' logic unindented and easy to read, instead of being buried several levels deep inside nested if statements checking each precondition.

Write a function describeTemperature(float $celsius): string using match(true) that returns 'Freezing' for <= 0, 'Cold' for <= 15, 'Mild' for <= 25, and 'Hot' otherwise.

function describeTemperature(float $celsius): string { return match (true) { $celsius <= 0 => 'Freezing', $celsius <= 15 => 'Cold', $celsius <= 25 => 'Mild', default => 'Hot', }; }

Use match (true) with each arm as a <= comparison against $celsius, ordered from lowest threshold to highest, with a default arm for the remaining case.