deftcode
Polski
← Back to blog

PHP in a nutshell: Operators and control flow

Read this in Polish
phpphp courseoperators

What this part is about

Operators in PHP look practically the same as in any other programming language — which is exactly what makes them treacherous. Most of them work just the way you expect, but a few will cost you a whole afternoon of debugging.

This part is about those exceptions, about controlling the flow with conditionals, loops, switch and match, and about loading files. We already covered == and === in part two, so here I’ll only refer back to them.


A tour of the operators

Group Operators
Arithmetic + - * / % ** (exponentiation, right-associative)
Assignment = += -= *= /= %= **= .= ??=
Comparison == != <> === !== < > <= >= <=>
Logical && || ! and and or xor
String . (concatenation)
Bitwise & | ^ ~ << >>
Error suppression @
Type instanceof

Two remarks right away. The first is about concatenation, which is done with a dot ., not a plus + — 'a' + 'b' throws a TypeError instead of joining the two strings. The second is about the logical operators and, or and xor, which are not synonyms for && and ||. You’ll see why in a moment.

The @ operator

$data = @file_get_contents($path); // silences the warning when the file is missing — $data will be false

@ suppresses diagnostics for the expression it precedes. Even though it may look like a convenient shortcut, it’s almost always bad practice: it hides not only the error you expect, but every other error in the same expression too — a typo in a variable name, missing permissions, exhausted memory. The error doesn’t go away, it just stops being visible: when the file is missing, file_get_contents() returns false, so $data is false, and nothing about it ends up in the logs. Check the condition explicitly or catch an exception instead.


Increment and decrement

$i++; // post-increment: returns the OLD value, then increments
++$i; // pre-increment: increments, then returns

The code above behaves the way you’d expect if you have experience with other programming languages. The code below — not so much:

$n = null; $n++; // 1 — incrementing null works
$n = null; $n--; // null — decrementing null DOES NOTHING (Warning)
$s = 'abc'; $s++; // 'abd' — Perl-style alphanumeric increment (Deprecated)
$s = 'abc'; $s--; // 'abc' — decrementing a string DOES NOTHING (Deprecated)

There’s no good reason for this asymmetry — it’s historical. String increment goes further than you’d expect: 'az' becomes 'ba', and 'Zz' becomes 'AAa', because the carry propagates to the left like in an odometer.

PHP is gradually phasing these behaviours out. In PHP 8.5, decrementing null raises a warning, and ++ and -- on a non-numeric string are deprecated. If you really need the “next” string, use the str_increment() function (available since PHP 8.3) — str_increment('az') returns 'ba'.

Other types follow suit. On booleans ++ and -- change nothing and raise a warning, while on arrays, objects and resources they throw a TypeError.

The practical takeaway: don’t rely on ++ for anything other than numbers. You’ll run into this most often when counting occurrences in an array:

$tags = ['php', 'js', 'php'];
$counts = []; // tag => number of occurrences
foreach ($tags as $tag) {
$counts[$tag] ??= 0; // on a tag's first occurrence, set 0
$counts[$tag]++;
}
// $counts: ['php' => 2, 'js' => 1]

Without the ??= line the result would be the same, but for every new tag PHP would raise an Undefined array key warning, because ++ tries to read a key that isn’t in the array yet.


The precedence trap

Below is the most important part of this whole section.

$result = true and false;
var_dump($result); // true (!)

The reason: = has a higher precedence than and. So the parser sees:

($result = true) and false; // in practice: assign true to $result,
// then evaluate `true and false` and throw the result away

The assignment runs first, its result (true) goes into and, and the value of the whole expression is discarded. What you meant to write is:

$result = true && false; // false

&& and || have a higher precedence than =, so they behave intuitively. and and or have the lowest precedence in the entire language — lower even than assignment.

Precedence table

From highest to lowest:

Precedence Operators
1 **
2 ++ -- ~ casts @
3 instanceof
4 !
5 * / %
6 + -
7 << >>
8 . (since PHP 8 lower than + and -)
9 < <= > >=
10 == != === !== <> <=>
11–13 &, then ^, then |
14–15 &&, then ||
16 ?? (right-associative)
17 ? : (since PHP 8 non-associative — nesting requires parentheses)
18 = += -= …
19–21 and, xor, or

Non-associative means that $a ? 'x' : $b ? 'y' : 'z' without parentheses is a compile error. You have to write $a ? 'x' : ($b ? 'y' : 'z'). PHP used to evaluate this from the left, the opposite of other languages, which is why the syntax was first deprecated (7.4) and then removed (8.0).

Don’t learn it by heart. Remember two things: assignment is lower than &&, but higher than and, and when in doubt, parentheses cost nothing.

Changed in PHP 8: 'sum: ' . $a + $b (where $a and $b are numbers) used to be parsed as ('sum: ' . $a) + $b — an attempt to add a number to a string. Now . has a lower precedence than +, so the expression means 'sum: ' . ($a + $b). More sensible, but keep in mind that this change is not backward compatible, and older versions of the language may behave differently.

The only reasonable use of and/or is precisely their low precedence — as in the old and now rarely used idiom $f = fopen(…) or die(…). In new code, just don’t use them.


Conditionals

In PHP they look the same as in C, Java or JavaScript. Two things are worth noting: elseif is usually written as one word, and templates have a syntax of their own.

if ($a) {
// …
} elseif ($b) {
// …
} else {
// …
}

In templates the alternative syntax is more convenient, because you don’t lose track of curly braces in a thicket of HTML:

views/status.php
<?php if ($isActive): ?>
<p>Active</p>
<?php elseif ($isPending): ?>
<p>Pending</p>
<?php else: ?>
<p>Inactive</p>
<?php endif; ?>

endforeach;, endfor;, endwhile; and endswitch; work the same way.

With curly braces PHP also accepts else if (two words) and it works identically, but elseif is the common choice — the PSR-12 standard recommends it too, and it’s the one to stick with. In the alternative syntax only elseif works, and else if is a parse error there.


Loops

PHP has the four loops familiar from other C-style languages: for, while, do-while, and foreach for iterating over arrays. The syntax itself holds no surprises — things get interesting in the details, which the rest of this section covers.

for ($i = 0; $i < 10; $i++) { }
while ($cond) { }
do {
// the body runs at least once
} while ($cond);
$arr = ['a' => 1, 'b' => 2];
foreach ($arr as $value) { } // $value: 1, then 2
foreach ($arr as $key => $value) { } // $key: 'a', $value: 1; then $key: 'b', $value: 2

The condition is evaluated on every iteration

// ❌ count() called on every iteration
for ($i = 0; $i < count($a); $i++) { }
// ✅ counted once
for ($i = 0, $n = count($a); $i < $n; $i++) { }

The count() example isn’t the best one, admittedly, because on an array it’s a cheap operation — PHP keeps the array’s size in its internal structure and doesn’t recount anything. But if the condition contained, say, a database query, it would run on every iteration, and that would make a real difference.

break and continue in nested loops

After break and continue you can give a number that says how many levels of nesting the statement applies to. 1 (the default) means the current loop, 2 — the loop that encloses it, and so on.

$rows = [[1, null, 2], [3, 4], [5, 'stop', 6], [7]];
foreach ($rows as $row) { // outer loop
foreach ($row as $cell) { // inner loop
if ($cell === null) {
continue 2; // next iteration of the OUTER loop, i.e. the next $row
}
if ($cell === 'stop') {
break 2; // exit both loops at once
}
echo $cell, ' ';
}
}
// prints: 1 3 4 5

This solves the problem that other languages handle with labels or a flag.

Watch out for switch inside a loop. For break and continue, PHP treats switch as another level of nesting, so a plain continue inside switch acts like break: it only leaves the switch instead of moving on to the next iteration of the loop. Since PHP 7.3 this raises a warning. To move on to the next element, use continue 2:

foreach ($items as $item) {
switch ($item['type']) {
case 'skip':
continue 2; // next $item — skips the rest of the foreach body
case 'stop':
break 2; // exits both switch and foreach
}
process($item);
}

foreach by reference — the classic bug

This bug is made of two things that are perfectly innocent on their own.

First: a loop in PHP has no scope of its own. The foreach variable doesn’t disappear after the loop. It lives on wherever the loop was (in a function or in the script), holding the value from the last iteration:

foreach ([1, 2, 3] as $v) { }
echo $v; // 3 — $v still exists

Second: & turns $v into a reference. Without & the loop variable is a copy of the element. With & it’s the element itself, so changing $v changes the array:

$prices = [10, 20, 30];
foreach ($prices as &$price) {
$price *= 2;
}
unset($price); // ← ALWAYS after a by-reference loop
// $prices: [20, 40, 60]

The simplest way to think about foreach is as an assignment repeated on every iteration:

foreach ($a as $v) { } // on every iteration: $v = $a[$i]; — a copy of the value
foreach ($a as &$v) { } // on every iteration: $v = &$a[$i]; — a reference to the element

Combining the two produces the bug. If you skip unset(), $v is still a reference to the last element after the loop. All it takes is a second, ordinary loop with the same variable name:

$a = [1, 2, 3];
foreach ($a as &$v) { } // after the loop $v is still a REFERENCE to $a[2]
foreach ($a as $v) { } // every iteration assigns to $v, i.e. writes to $a[2]
var_dump($a); // [1, 2, 2] — not [1, 2, 3]
Diagram: after the by-reference loop, $v points to the array's third element, so the second loop overwrites it on every iteration and the array ends up as 1, 2, 2
After the first loop $v is still a reference to $a[2]. The second loop assigns to $v, which writes straight into $a[2].

The second loop doesn’t create a new variable — it runs $v = $a[$i] on the existing $v, which is still a reference to $a[2]. So every one of those assignments lands in $a[2]. Iteration by iteration:

Second loop iteration Written to $v (i.e. to $a[2]) $a after the iteration
1 $a[0] = 1 [1, 2, 1]
2 $a[1] = 2 [1, 2, 2]
3 $a[2] = 2 (copies itself) [1, 2, 2]

That’s why the rule is unconditional: call unset() after every by-reference loop. Alternatively, avoid references and use array_map(), as in the part on arrays.


switch versus match

Both constructs pick one of their branches based on the value you give them — in the examples below that’s $code. PHP compares it with the branch values one by one and picks the first match. They differ in what happens next: switch executes the statements in the branch (assignments, function calls, anything), while match is an expression — each branch returns a single value, which you assign or return right away.

// switch — LOOSE comparison (==), falls through, executes statements
switch ($code) {
case 200:
case 201:
$msg = 'OK';
break; // forget it and execution falls through to default
default:
$msg = 'Unknown';
}
// match (PHP 8) — STRICT comparison (===), an expression, no fall-through
$msg = match ($code) {
200, 201 => 'OK',
404 => 'Not found',
default => 'Unknown',
}; // semicolon at the end — it's an expression, not a statement
switch match
Comparing $code with the branch value == loose === strict
Falls through to the next case Yes — needs break No
Returns a value No (statement) Yes (expression)
No match and no default Silently does nothing Throws \UnhandledMatchError
Branch body Multiple statements A single expression only

Two rows of this table make the biggest difference in practice. Strict comparison means that match ('200') won’t hit the 200 branch — and that’s a good thing, because switch would silently match it. No match throws an exception, so adding a new enum value without handling it in match shows up immediately, not a month later.

match (true) instead of an elseif chain

$tier = match (true) {
$points >= 1000 => 'gold',
$points >= 100 => 'silver',
default => 'bronze',
};

The branches are checked in order, and the first true one wins. It’s a more readable way of writing what would otherwise be an if/elseif chain with a repeated assignment.

match doesn’t fully replace switch: switch can execute multiple statements in a single branch, while match accepts only a single expression. If you need several statements to produce the result, move them into a separate function or method and just call it from match:

$price = match ($customerType) {
'b2b' => calculateB2bPrice($order), // all the discount logic lives in the function
default => $order->basePrice,
};

There’s one more difference worth knowing: switch and match evaluate their argument once, whereas an if/elseif chain evaluates the condition separately in every branch. This matters when the argument is an expensive call — for example, a function that queries the database.


include and require

A real project doesn’t fit in a single file. Configuration, helper functions and HTML templates live in separate files, so you need a way to use code from one file in another. That’s what include and require are for: they load the given PHP file and execute it at the point of the call, as if its contents were pasted right there. Both do the same thing as long as the file exists — they differ in what happens when it doesn’t:

When the file is missing Use for
include A warning, the script carries on Optional templates
require Throws Error — if uncaught, stops the script Everything essential
include_once / require_once As above, but make sure the file isn’t loaded twice Legacy code; replaced by autoloading today

require is the default choice. include only makes sense when a missing file is genuinely acceptable.

require __DIR__ . '/../config.php'; // __DIR__ makes the path independent of the working directory

A file can return something

This is a popular and readable way to load configuration in PHP — Laravel, for example, keeps its configuration this way:

config.php
<?php
return [
'db' => ['host' => 'localhost'],
];

The file that needs the configuration simply assigns the result of require to a variable:

src/bootstrap.php
<?php
declare(strict_types=1);
$config = require __DIR__ . '/../config.php';

A return placed directly in the file — outside any function or class — stops executing that file, and the returned value becomes the result of require. It works exactly the same with include. What both statements return depending on the situation:

File with return File without return File missing
require the returned value 1 throws Error — if uncaught, stops the script
include the returned value 1 a warning and false, the script carries on

Capturing a template’s output

A template loaded with include prints its HTML immediately, straight to the browser. Sometimes, though, you want it in a variable — to send it by email, save it to a file, or insert it into a larger layout:

ob_start(); // from now on, everything that would be printed goes into a buffer
include __DIR__ . '/views/invoice.php'; // the template prints HTML — but into the buffer, not to the browser
$html = ob_get_clean(); // take the buffer's contents as a string and stop buffering

Thanks to output buffering, the HTML the template would normally send to the browser ends up in the $html variable. Many simple PHP template engines are built on this mechanism.

Watch out for one thing: a loaded file sees all the variables from the place where it was loaded, and can change them. Take a file that sets a variable:

helpers.php
<?php
$user = 'admin';

and an index.php file that loads it while already having its own $user variable:

index.php
<?php
$user = 'john';
require __DIR__ . '/helpers.php';
echo $user; // admin — nothing in index.php hints at this

In a small script that’s convenient; in a larger project it’s a trap: to understand where a variable’s value came from, you have to look through every loaded file. That’s why it’s better for a file to hand data back with return (like config.php above) rather than quietly setting variables.

In modern code you write require practically once — for vendor/autoload.php. Composer’s PSR-4 autoloading takes care of the rest. I’ll cover Composer and autoloading in a separate series on advanced PHP.


What’s next

You now know operators, conditionals, loops and loading files. Time for functions, which let you reuse the same code many times.

In the next part: defining functions in PHP, parameter and return types, named and variadic arguments, passing by reference, and then closures — function () use ($x), arrow functions, the f(...) syntax, and what callable means in PHP.

Comments