Skip to content
beginner Phase 2 · Types, Functions, and Control Flow

Conditionals and Loops

Branch with if, match, switch and iterate with foreach and for.

45m
0 problems
Topic Progress 0%

== versus ===: The Table That Ends the Flame War

A registration form accepts "0" for a quantity field. The validation reads if ($qty == 0) { reject } — and rejects nothing, because in PHP the string "0" and the integer 0 are equal under loose comparison. The same instinct that makes == feel convenient makes "0" disappear into a branch meant for empty input. The default in modern PHP is strict: === and !== compare type and value, and you reach for == only with a documented reason.

Loose comparison still exists everywhere, so you must be able to predict it. The load-bearing rules in PHP 8:

Comparison Result (PHP 8) Note
'1' === 1 false types differ — always
'1' == 1 true numeric string converts to number
'0' == 0 true the form-field trap
'0' == false true both falsy-ish under loose rules
0 == 'apples' false changed in PHP 8 (was true in 7)
0 == '' false changed in PHP 8 (was true in 7)
null == false true null is loose-equal to falsy values
null === false false distinct types, distinct values

The PHP 8 row is the one that matters for version-sensitive code: comparing a number to a non-numeric string used to cast the string to a number ('apples' → 0, so 0 == 'apples' was true); PHP 8 flips it to a string-vs-string comparison instead, and the same change made 0 == '' false. Code written for PHP 7 that relied on if ($x == 0) catching arbitrary strings silently changes behavior when you upgrade — which is precisely the kind of semantic drift flagged back in Installing PHP and a Local Server.

Beyond the primitives, the strict variants come with parameters that default to the unsafe behavior:

in_array($needle, $list);              // loose — can match across types
in_array($needle, $list, true);        // strict — same type and value

array_search($needle, $list, true);    // strict search, returns key or false

array_search returning false when the needle sits at key 0 is the sibling bug: check === false, never ==, or if ($key = array_search(...)) skips the element at index zero entirely. And the null checks: $x === null is the correct "is this null" test, because $x == null also matches 0, '', false, and [].

The rule of thumb to state in interviews: strict by default, loose only when you deliberately want PHP's numeric-string coercion — and in a strict-typed codebase you rarely do, because untrusted strings should have been validated at the boundary rather than silently coerced in a comparison.

One loose-typing detail surfaces in conditions constantly: integer-like string array keys are converted to integers. $row['0'] and $row[0] address the same element, because the key '0' became int(0) the moment it entered the array. The same does not happen to values — only keys — so isset($row['0']) is true when you stored $row[0]. When key types matter (a JSON object with both "1" and 1 keys), normalize with array_map('strval', array_keys($a)) or cast deliberately before lookup.

And when a comparison must return ordering rather than a truth value, PHP 7's spaceship operator <=> gives -1, 0, or 1 in one expression — the canonical use is a sort comparator:

usort($users, fn (array $a, array $b) => $a['score'] <=> $b['score']);

It composes for multi-key sorts ($a['score'] <=> $b['score'] ?: $a['name'] <=> $b['name']) and behaves sanely on mixed types, unlike the subtraction idiom $a - $b, which returns garbage when either side is a string.

switch Falls Through, match Does Not: Choosing the Right Branch

Both switch and the PHP 8.0 match expression dispatch a value against several candidates. They differ in three ways that decide which one you should write.

// switch — statement, LOOSE comparison, falls through without break
switch ($code) {
    case 200:
    case 201:
    case 204:
        $status = 'ok';
        break;
    case 404:
        $status = 'not found';
        break;
    default:
        $status = 'error';
}
// match — expression, STRICT (===) comparison, no fallthrough possible
$status = match ($code) {
    200, 201, 204, 206 => 'ok',
    301, 302, 307, 308 => 'redirect',
    404, 410           => 'not found',
    500, 502, 503, 504 => 'server error',
    default            => throw new UnexpectedValueException("unknown code $code"),
};

Fallthrough is the classic switch hazard: omit break and execution continues into the next case's body. Grouped cases use it deliberately — three case labels stacking before one body — but an accidental missing break silently runs a second branch, which is why style guides require a // fall through comment on intentional cases and reviewers treat unmarked ones as bugs. match cannot fall through at all: every arm evaluates to a value, so there is no statement sequence to spill out of.

Comparison semantics differ too. switch uses ==, so switch ('1') { case 1: ... } matches — and on PHP 7, switch ("apples") matched case 0:. match uses ===, so the same dispatch distinguishes '1' from 1, consistent with the strict-by-default rule above.

Role in the language is the deepest difference. switch is a statement: it assigns or acts, it needs $status pre-declared, and the compiler cannot prove every path assigns it. match is an expression: it returns a value, it can appear inside another expression ($label = match ($s) { ... }), and its default can throw, making an unexpected value a runtime error instead of a silent empty string. When every arm has no useful value, match still works — each arm can be null or a function call — and it remains the more predictable construct.

match also gets two syntax privileges that read well in real code: arms may span multiple lines without wrapping the whole expression in parentheses, and since PHP 8.0 arms may end in throw because throw is an expression. Guarded arms do not exist — for range conditions, keep the switch (true) idiom or plain if/elseif chains:

switch (true) {
    case $n < 0:  $band = 'negative'; break;
    case $n < 10: $band = 'small';    break;
    default:      $band = 'large';
}

One PHP 8.0 syntax note for ternaries, the third member of this family: nested ternaries must be parenthesized — a ? b : c ? d : e is a compile error; write a ? b : (c ? d : e). Unparenthesized nesting was deprecated in 7.4 and removed because its left-associative parsing produced results nobody predicted.

The modern partner of match is the PHP 8.1 enum, where the pair really shines: an enum value as the match subject guarantees exhaustiveness — add a new enum case and every match without a default starts throwing UnhandledMatchError until you handle it, turning "did we cover every status?" from a code-review question into a test failure:

enum OrderState: string {
    case Pending   = 'pending';
    case Paid      = 'paid';
    case Shipped   = 'shipped';
    case Cancelled = 'cancelled';
}

$html = match ($state) {
    OrderState::Pending  => '<a href="/pay">Pay now</a>',
    OrderState::Paid     => '<a href="/ship">Awaiting shipment</a>',
    OrderState::Shipped  => 'Delivered',
    OrderState::Cancelled => 'Cancelled',
};

This is the single most common place you will write match in PHP 8 codebases, and knowing the exhaustiveness property is what separates a passing mention of the feature from real usage.

foreach Is the Workhorse: Iterating Without Footguns

PHP's loop family is foreach, for, while, and do...while. foreach owns array and object iteration, and knowing exactly what it does to the array underneath is the difference between safe code and undefined behavior.

foreach ($users as $user) { … }             // values only
foreach ($users as $id => $user) { … }      // preserve keys
foreach ($users as &$user) { … }            // edit in place — then unset($user)

By value, foreach iterates the array's elements without copying it (PHP 7+), and modifying elements inside the loop is safe. What is not safe is adding or removing elements mid-iteration: the engine may or may not see changes made after the current position, and the documented rule is that doing so yields undefined iteration. The safe patterns are filtering with array_filter or foreach, or collecting into a new array:

$adults = [];
foreach ($users as $user) {
    if ($user['age'] >= 18) { $adults[] = $user; }
}

The by-reference form exists for in-place edits — but the leftover reference trap from the variables topic returns here more than anywhere else:

foreach ($users as &$user) { $user['active'] = true; }
unset($user);   // mandatory: $user still aliases the LAST element
$last = null;   // without the unset, this line rewrites $users[last]

For keys: PHP arrays keep insertion order, and foreach respects it, so a list built by appending iterates predictably. Integer keys are preserved — there is no automatic reindexing — which is why array_values() after array_filter matters when you need a clean 0..n-1 list.

for and while still have their places. for fits counted iteration over a range; while fits "until a condition flips", most often reading streams:

while (($line = fgets($fh)) !== false) { … }   // EOF is false — strict test required

That !== false is not optional: a line containing "0" is a truthy/falsy trap if you write while ($line = fgets($fh)). do...while runs the body at least once, which suits prompts and retry loops that must attempt before testing.

Escape hatches: break and continue take a level — break 2 exits a nested loop entirely, continue 2 jumps to the next iteration of the outer one — and levelled jumps are often the cleanest way to search a two-dimensional structure. And PHP 5.5's generators turn iteration into a lazy stream with yield, which foreach consumes transparently:

function rows(): Generator {
    foreach ($pdo->query($sql) as $row) { yield $row; }
}
foreach (rows() as $row) { … }   // one row in memory at a time

Complexity is the unglamorous close: every loop over an array is O(n) with hash-table iteration, but membership inside that loop is O(1) per lookup on an array used as a set — so the difference between O(n²) (in_array($x, $haystack) nested in a loop) and O(n) (build a $seen[$id] = true map first) is one line, and it is the single most common performance review comment on PHP code.

Before reaching for a hand-written loop at all, check the array toolbox — array_map, array_filter, and array_reduce do most iteration jobs in one expression and are the subject of the next topic. One fact worth knowing now: sorting with usort and a closure is stable since PHP 8.0 — elements comparing equal keep their original relative order — so a "sort by score, then by name" pipeline can be two passes instead of one compound comparator:

usort($rows, fn ($a, $b) => $a['name'] <=> $b['name']);   // secondary
usort($rows, fn ($a, $b) => $b['score'] <=> $a['score']); // primary — stable pass

On PHP 7 that second pass would silently scramble the first; the version note in the install topic is why such a detail belongs in review checklists.

Quiz

1. In PHP 8, what is the result of `0 == 'apples'`, and how does it differ from PHP 7?

Question 1 options

2. What is the key difference between switch and match on comparison?

Question 2 options

3. After `foreach ($rows as &$row) { $row['done'] = true; }` the next line `$row = null;` causes a bug. Why?

Question 3 options

4. A nested loop calls in_array($id, $seen) on a growing array for 50,000 rows. What is the fix and the effect?

Question 4 options

Flashcards

Question

=== vs == in PHP, and the two comparisons PHP 8 changed?

Answer

=== requires identical type AND value ('1' === 1 is false); == applies coercion ('1' == 1 is true, and still '0' == 0 is true). PHP 8 flipped 0 == 'apples' and 0 == '' from true to false by comparing numbers to non-numeric strings as strings. Default to ===; use == only deliberately.

Question

switch vs match — four differences

Answer

switch: a statement, loose == comparison, falls through without break, default optional. match (PHP 8.0+): an expression that returns a value, strict === comparison, cannot fall through, and throws UnhandledMatchError when no arm matches and there is no default. Arms may end in throw.

Question

When does a foreach by-reference loop bite, and what is the cure?

Answer

After the loop, the variable still aliases the array's last element, so a later assignment silently overwrites it. Cure: unset($variable) immediately after the loop. Also, adding or removing elements during any foreach is undefined — filter into a new array instead.

Question

Why must a stream-reading while loop use a strict comparison?

Answer

fgets() returns the line as a string, and '0' is falsy — `while ($line = fgets($fh))` stops early on a line containing only 0. Write `while (($line = fgets($fh)) !== false)` so EOF is the only terminator.

Question

How do you linearize a nested membership check, and what is the complexity change?

Answer

Replace repeated in_array($x, $list) (O(n) each, O(n²) in a loop) with a hash map: build $seen[$value] = true once, then isset($seen[$value]) is O(1) per lookup — O(n) overall. PHP arrays are ordered hash maps, so this idiom is idiomatic, not a hack.

Revision Notes

Key Takeaways

  • 1. Strict comparison is the modern default; loose == coerces across types and still treats '0' == 0 as true
  • 2. PHP 8 changed number-vs-non-numeric-string comparison (0 == 'apples' now false) — upgraded code can silently change branches
  • 3. switch: statement, loose ==, needs break to stop fallthrough. match: expression, strict ===, no fallthrough, throws UnhandledMatchError without a matching arm
  • 4. foreach by value is safe for element edits; by reference needs unset($v) afterwards, and mutating the array's size mid-loop is undefined
  • 5. Membership inside a loop is the classic O(n²) — build a value-keyed map once and every lookup becomes O(1)

Interview Tips

  • • "==' vs '==='?" — give the coercion rule plus the two PHP 8 changes, then the practical rule: === by default, in_array with strict=true, and === false for array_search results because key 0 is falsy
  • • "What is match and when do you use it instead of switch?" — expression vs statement, === vs ==, no fallthrough, UnhandledMatchError; add that arms may throw, so an unknown value fails loudly
  • • "Name a PHP upgrade gotcha" — 0 == 'apples' flipping true→false, plus nested ternaries becoming a compile error in 8.0; this signals real migration experience
  • • "How do you find duplicates in an array efficiently?" — the $seen[$id] = true map, O(n), versus nested in_array at O(n²); mention array_count_values as the built-in for frequency counting

Cheat Sheet

Conditionals and Loops

Comparison — strict by default:

$x === $y    // type AND value        $x !== $y
$x == $y     // loose coercion — only with a reason
in_array($n, $list, true);        // ALWAYS pass strict
if (array_search($n, $list) === false) { … }   // key 0 is falsy — never use ==
$x === null                       // $x == null also matches 0, '', false, []

PHP 8 changed: 0 == 'apples' is now false (was true), and 0 == '' is now false. '0' == 0 is still true — the form-field trap.

switch vs match:

switch match (8.0+)
Kind statement expression (returns a value)
Compares with == (loose) === (strict)
Fallthrough yes — needs break/return impossible; arms are cond => expr
No match default optional default or UnhandledMatchError
Ranges switch (true) idiom not supported — use if

Nested ternaries must be parenthesized in PHP 8 (a ? b : (c ? d : e)).

Loops:

foreach ($a as $k => $v) {}      // default; keys preserved, order preserved
foreach ($a as &$v) { … } unset($v);   // in place — unset the reference after
while (($line = fgets($fh)) !== false) {}   // strict: '0' is falsy
break 2;  continue 2;            // jump levels

Never add/remove elements mid-foreach; filter into a new array instead. Nested in_array is O(n²) — flip to a $seen[$id] = true map for O(n).

Continue Learning

Before You Start

Next Topics

Related Reading