Control Structures

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against PHP 8.5, checked on 2026-10-05. It walks through every statement of the manual’s control structures chapter, from conditions and loops to match and file inclusion, with the pitfalls that each one is known for.

if, elseif and else

if executes its block when the condition is truthy (see the falsy values in Types and the Type System). elseif and else if are equivalent with braces; with the alternative colon syntax only elseif is valid.

<?php

declare(strict_types=1);

function loanStatus(int $daysLate): string
{
    if ($daysLate <= 0) {
        return 'on time';
    } elseif ($daysLate <= 7) {
        return 'late';
    } else {
        return 'overdue: fines apply';
    }
}

foreach ([0, 3, 12] as $days) {
    echo $days, ' => ', loanStatus($days), "\n";
}
0 => on time
3 => late
12 => overdue: fines apply

Always use braces, even for one-line bodies; PER Coding Style requires them, and they prevent the "dangling statement" bugs that braceless bodies invite.

Source: if, elseif/else if and else

The alternative syntax

if, while, for, foreach and switch have an alternative syntax: the opening brace becomes : and the closing brace becomes endif;, endwhile;, endfor;, endforeach; or endswitch;. It exists for templates, where it is easier to read than braces scattered among HTML; do not use it in plain PHP code. Mixing both syntaxes in the same block is not allowed.

<?php

declare(strict_types=1);

$loans = [['title' => 'Kindred', 'late' => false], ['title' => 'Solaris', 'late' => true]];
$e = static fn (string $s): string => htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
?>
<?php if ($loans === []): ?>
<p>No loans.</p>
<?php else: ?>
<ul>
<?php foreach ($loans as $loan): ?>
  <li class="<?= $loan['late'] ? 'late' : 'ok' ?>"><?= $e($loan['title']) ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>

while and do-while

while checks its condition before each iteration; do … while checks it after, so its body runs at least once.

<?php

declare(strict_types=1);

$queue = ['978-0-00-000001-1', '978-0-00-000002-8'];
while (($isbn = array_shift($queue)) !== null) {      // parenthesise the assignment
    echo "Processing {$isbn}\n";
}

$attempt = 0;
do {
    $attempt++;
    $ok = $attempt >= 3;                               // e.g. retrying a network call
} while (!$ok);
echo "Succeeded after {$attempt} attempts\n";

When the condition contains an assignment, compare explicitly (!== null, !== false): a bare while ($line = fgets($fh)) stops at a line containing just "0".

Source: while and do-while

for

for (init; condition; step) is the C-style loop. Each of the three parts may contain several comma-separated expressions or be empty; an empty condition means "forever". Avoid calling count() in the condition of a loop that does not change the array: compute it once in the init part.

<?php

declare(strict_types=1);

$shelf = ['Solaris', 'Kindred', 'Dawn'];

for ($i = 0, $n = count($shelf); $i < $n; $i++) {
    echo $i + 1, '. ', $shelf[$i], "\n";
}

Source: for

foreach

foreach iterates over arrays and Traversable objects (iterators, generators, IteratorAggregate). It works on a copy-on-write snapshot of an array: modifying the array inside the loop does not change what the loop iterates over (unless you iterate by reference).

<?php

declare(strict_types=1);

$catalogue = [
    '978-0-00-000001-1' => ['title' => 'Kindred', 'year' => 1979],
    '978-0-00-000003-5' => ['title' => 'Solaris', 'year' => 1961],
];

foreach ($catalogue as $book) {                                   // values only
    echo $book['title'], "\n";
}

foreach ($catalogue as $isbn => $book) {                          // keys and values
    echo "{$isbn}: {$book['title']}\n";
}

foreach ($catalogue as $isbn => ['title' => $title, 'year' => $year]) {   // destructuring (7.1 keyed)
    echo "{$title} ({$year})\n";
}

foreach ((function (): Generator { yield 'a' => 1; yield 'b' => 2; })() as $k => $v) {
    echo "{$k}={$v} ";                                            // generators work too
}
echo "\n";

Source: foreach

foreach by reference and its pitfall

Prefixing the value with & iterates by reference, so assignments change the array. After the loop, the variable remains a reference to the last element. Reusing that variable in a later loop silently overwrites the last element on every iteration. Always unset() the variable after a by-reference loop:

<?php

declare(strict_types=1);

$titles = ['solaris', 'kindred', 'dawn'];

foreach ($titles as &$title) {
    $title = ucfirst($title);
}
// unset($title);   // <- the fix: without it, the next loop corrupts the array

foreach ($titles as $title) {
    // each iteration assigns to $title, which is still a reference to $titles[2]
}

print_r($titles);
Array
(
    [0] => Solaris
    [1] => Kindred
    [2] => Kindred
)

The last element became Kindred: on the final iteration of the second loop, $titles[2] was assigned the value of $titles[1]. Uncomment the unset() and the output is Solaris, Kindred, Dawn. Often array_map() or a loop that writes $titles[$key] = … avoids references altogether.

Source: foreach (the warning about references)

break and continue

break ends the current for, foreach, while, do-while or switch; continue skips to the next iteration. Both accept a numeric argument (a literal, since 5.4) for how many enclosing structures to break out of or continue. continue inside a switch acts like break (and emits a compile warning since 7.3, suggesting continue 2).

<?php

declare(strict_types=1);

$shelves = [
    'A' => ['Solaris', 'Kindred'],
    'B' => ['Dawn', 'The Dispossessed'],
];

$wanted = 'Dawn';
foreach ($shelves as $shelf => $books) {
    foreach ($books as $position => $title) {
        if ($title === $wanted) {
            echo "Found {$wanted} on shelf {$shelf}, position {$position}\n";
            break 2;                         // leave both loops
        }
    }
}

foreach ([1, 2, 3, 4, 5, 6] as $n) {
    if ($n % 2 === 0) {
        continue;                            // skip even numbers
    }
    echo $n, ' ';
}
echo "\n";                                   // 1 3 5

Source: break and continue

switch

switch compares its subject with each case using loose comparison (==) and executes from the first match until a break (or the end), so a missing break falls through to the next case. default runs when nothing matches. Since PHP 8.5, terminating a case with a semicolon instead of a colon is deprecated.

<?php

declare(strict_types=1);

function shelfFor(string $genre): string
{
    switch ($genre) {
        case 'sf':
        case 'fantasy':              // fall-through: both genres share a shelf
            $shelf = 'Speculative fiction';
            break;
        case 'poetry':
            $shelf = 'Poetry';
            break;
        default:
            $shelf = 'General';
    }
    return $shelf;
}

echo shelfFor('fantasy'), ' / ', shelfFor('crime'), "\n";   // Speculative fiction / General

Source: switch

match (PHP 8.0)

match is an expression: it evaluates to the value of the matching arm. Compared with switch it:

  • compares with strict identity (===), so '1' does not match 1;

  • has no fall-through: each arm is a single expression, and several conditions can share an arm, separated by commas;

  • must be exhaustive: if no arm matches and there is no default, it throws \UnhandledMatchError;

  • evaluates arms lazily, top to bottom, stopping at the first match.

<?php

declare(strict_types=1);

enum Format: string { case Hardcover = 'hc'; case Paperback = 'pb'; case Ebook = 'eb'; }

function loanDays(Format $format): int
{
    return match ($format) {
        Format::Hardcover, Format::Paperback => 21,
        Format::Ebook => 14,
    };
}

echo loanDays(Format::Ebook), "\n";                     // 14

// match (true): the idiom for a chain of conditions
function fineCategory(int $daysLate): string
{
    return match (true) {
        $daysLate <= 0 => 'none',
        $daysLate <= 7 => 'reduced',
        default => 'full',
    };
}

echo fineCategory(10), "\n";                            // full

try {
    echo match ('1') { 1 => 'int one' };                // strict: '1' !== 1
} catch (\UnhandledMatchError $e) {
    echo $e->getMessage(), "\n";                        // Unhandled match case '1'
}

Because throw is an expression, an arm can throw: default => throw new DomainException(…). When the subject is an enum and every case is listed, static analysers can verify exhaustiveness, and adding a case later surfaces as an error rather than a silent fall-through. Prefer match over switch for anything that produces a value.

Source: match

declare

declare(directive=value) sets execution directives for a block or, written as a statement, for the rest of the file. There are three directives:

  • strict_types=1 — strict scalar type checking for calls and returns made in this file (see Types and the Type System). It must be the first statement of the file and cannot be used in block form.

  • ticks=N — calls functions registered with register_tick_function() every N low-level statements. Rarely used; pcntl_async_signals() replaced its main use case, signal handling.

  • encoding='…' — the script’s encoding, for builds with Zend multibyte support. Rarely needed: write UTF-8.

<?php

declare(strict_types=1);

declare(ticks=1) {
    register_tick_function(static function (): void { static $n = 0; $n++; if ($n === 3) echo "tick 3\n"; });
    $a = 1;
    $b = 2;
    $c = $a + $b;
}
echo $c, "\n";

Source: declare

goto

goto label; jumps to label: in the same file and the same function. It cannot jump into a loop or switch, or out of a function, but can jump out of loops. It is almost never the clearest option; break N, early return or extracting a function usually is.

<?php

declare(strict_types=1);

foreach ([3, 8, 15] as $n) {
    if ($n > 10) {
        goto tooLarge;
    }
}
echo "all small\n";
goto done;

tooLarge:
echo "found a large value\n";

done:
echo "end\n";

Source: goto

return

return ends the current function and passes its value back. At the top level of an included file, return ends that file and becomes the value of the include/require expression; at the top level of the main script it ends the script. return without a value returns null (and is the only form allowed in a void function).

Source: return

include and require

include, require, include_once and require_once evaluate another PHP file in the current scope:

Statement If the file is missing or unreadable

require

fatal Error: the script stops

include

warning, and execution continues (the expression evaluates to false)

require_once, include_once

as above, but the file is included at most once per request, even if requested again

The file is resolved relative to the include_path and the current working directory for relative paths; always build absolute paths with __DIR__. An included file starts in HTML mode, so it must begin with <?php. Variables of the including scope are visible in the included file, and the value of the file’s top-level return becomes the value of the expression, which is how configuration files work:

<?php

declare(strict_types=1);

// Write a configuration file to include (normally it would already exist).
$configFile = sys_get_temp_dir() . '/library-config.php';
file_put_contents($configFile, <<<'PHP'
<?php
return [
    'loan_days' => 21,
    'max_loans' => 5,
];
PHP);

$config = require $configFile;            // value of the included file's return
echo $config['loan_days'], "\n";          // 21

var_dump(include_once $configFile);       // bool(true): already included, so not evaluated again

$missing = @include __DIR__ . '/does-not-exist.php';
var_dump($missing);                       // bool(false)

include_once and require_once consult the list of files already included during the request, whichever of the four statements included them; if the file is on the list they return true without evaluating it again, which is why the second call above does not return the configuration array.

In applications, classes are not included by hand: an autoloader (Composer’s) loads them on first use, as explained in Namespaces and Autoloading. require remains the tool for configuration files, the autoloader itself (require __DIR__ . '/vendor/autoload.php';) and templates.

Never build an include path from user input: include $_GET['page'] . '.php' is a local or remote file inclusion vulnerability, discussed in Web Security Fundamentals.

Where older books and tutorials are outdated

  • switch versus match. Books written for PHP 7 have only switch, with its loose comparison and fall-through; match (8.0) is usually the better choice.

  • Nested ternaries used as a compact if/elseif chain must be parenthesised since 8.0; match (true) is clearer.

  • each() loops. while (list($k, $v) = each($array)) was a common idiom; each() was deprecated in 7.2 and removed in 8.0. Use foreach.

  • include for classes. Books include every class file by hand; Composer’s PSR-4 autoloader does it now.

  • case …; with a semicolon is deprecated since 8.5.