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".
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
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 match1; -
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 withregister_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 |
|---|---|
|
fatal |
|
warning, and execution continues (the expression evaluates to |
|
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.
Source: include, require, include_once and require_once
Where older books and tutorials are outdated
-
switchversusmatch. Books written for PHP 7 have onlyswitch, with its loose comparison and fall-through;match(8.0) is usually the better choice. -
Nested ternaries used as a compact
if/elseifchain 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. Useforeach. -
includefor 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.
References
-
PHP Manual — PHP 8.5 deprecated features (case terminated by a semicolon)