Lesson 7 of 20

Loops

foreach: the Loop You Will Use Most

In real PHP work, almost every loop exists for one reason: you fetched a set of rows from a database or read a list from a file, and now you have to turn each one into a row of a table. foreach is built exactly for that. It walks through an array from start to finish, handing you one element per pass, and it never asks you how many there are.

There are two forms. foreach ($arr as $value) gives you just the values, which is what you want for a plain list. foreach ($arr as $key => $value) gives you the key as well, which is what you want for an associative array — a settings map, a row from a database keyed by column name, or a count of votes per option.

An important detail: by default foreach works on a copy of the array. Assigning to $value inside the loop changes only your local copy, and the original array is untouched. Beginners try this and conclude the loop is broken. It is not; you simply need to write back into the array by key, or build a new array as you go, or use array_map().

Since PHP 7.1 you can also destructure inside the loop header. If each element is itself an array, foreach ($rows as ['name' => $name, 'marks' => $marks]) pulls the fields straight into named variables, which makes the loop body considerably tidier than repeating $row['name'] everywhere.

Example
<?php
$subjects = ['Physics', 'Chemistry', 'Maths'];

foreach ($subjects as $subject) {
    echo "<li>" . htmlspecialchars($subject) . "</li>";
}

// With keys - the usual shape for a database row or a settings map
$marks = ['Physics' => 91, 'Chemistry' => 84, 'Maths' => 88];
foreach ($marks as $subject => $score) {
    echo "$subject: $score\n";
}

// Modifying $value does NOT change the array
$prices = [100, 200, 300];
foreach ($prices as $p) {
    $p = $p * 2;
}
print_r($prices);   // still [100, 200, 300]

// Write back by key instead
foreach ($prices as $i => $p) {
    $prices[$i] = $p * 2;
}
print_r($prices);   // [200, 400, 600]

// Destructuring a list of rows
$students = [
    ['name' => 'Ananya', 'marks' => 91],
    ['name' => 'Ravi',   'marks' => 78],
];
foreach ($students as ['name' => $name, 'marks' => $m]) {
    echo "$name scored $m\n";
}
Notes
  • In PHP 8, running foreach over something that is not an array or object produces a warning and skips the loop entirely. If your table renders as empty with a warning in the log, check whether the variable really holds an array — a failed query returning false is the usual culprit.

The Reference Trap Everybody Hits Once

PHP offers a way to modify the array in place: put an & before the variable, as in foreach ($arr as &$value). Now $value is a reference to the actual element, and assigning to it changes the array. It works, and it is the source of one of the strangest bugs in PHP.

The problem is that $value is still a reference after the loop ends, pointing at the last element. If you then write a second foreach over the same array reusing the name $value, every iteration of the second loop assigns into that last element. The result is an array whose final element has been silently replaced by the second-to-last one — a bug that looks like data corruption and gives you no error whatsoever.

The fix is a single line: unset($value); immediately after any loop that used &. That breaks the reference and makes the name safe to reuse.

The better fix is usually to avoid the reference altogether. Writing back by key does the same job with none of the risk, and array_map() expresses "transform every element" more clearly than a loop that mutates as it goes. Reserve &$value for the rare case where the array is large enough that copying it genuinely matters, and then unset it religiously.

Example
<?php
$items = ['a', 'b', 'c'];

// The reference form - works, but leaves a trap behind
foreach ($items as &$item) {
    $item = strtoupper($item);
}
print_r($items);   // ['A', 'B', 'C']  - so far so good

// $item is STILL a reference to $items[2]
foreach ($items as $item) {
    // each pass assigns into $items[2]
}
print_r($items);   // ['A', 'B', 'B']  <- the last element was overwritten

// The fix
$items = ['a', 'b', 'c'];
foreach ($items as &$item) {
    $item = strtoupper($item);
}
unset($item);      // break the reference - do this every time

// Usually better: no reference at all
$items = array_map('strtoupper', ['a', 'b', 'c']);
Notes
  • This is not a hypothetical. It appears in production code often enough that "unset after foreach by reference" is a standard review comment, and static analysis tools such as PHPStan flag it. If an array ends up with a duplicated last element and you cannot see why, this is the first thing to check.

for, while and do-while: Choosing the Right One

for is the counting loop. Its header holds three parts separated by semicolons — set up a counter, test whether to continue, and advance the counter — which puts all three in one place where you can check them together. Use it when you know how many times you want to run: generate ten pagination links, print a multiplication table, build a dropdown of years.

while is the loop for "keep going until something says stop". You use it when the number of iterations is not known in advance, and its most common real appearance in PHP is reading results one at a time: while ($row = $stmt->fetch()) keeps going until fetch() returns false because there are no more rows. That assignment-inside-a-condition idiom looks odd at first, but it is idiomatic and worth recognising.

do-while is the rare one. It runs the body first and checks the condition afterwards, guaranteeing at least one pass. Retry logic is its natural home — attempt something, then decide whether to try again. If you cannot name a reason the body must run at least once, use while.

One small thing worth doing in a for loop: calculate count($arr) once before the loop rather than in the condition, where it is re-evaluated on every pass. The performance difference is trivial for small arrays, but the habit also protects you from the confusing case where the loop body changes the array's length while it is running.

Finally, remember PHP has no block scope. A variable declared inside a loop still exists after it ends, which is occasionally useful and occasionally the reason a stale value from the previous loop leaks into the next one.

Example
<?php
// for - a known number of repetitions
for ($i = 1; $i <= 5; $i++) {
    echo "<a href=\"?page=$i\">$i</a> ";
}

// Compute the length once
$rows = ['a', 'b', 'c', 'd'];
for ($i = 0, $n = count($rows); $i < $n; $i++) {
    echo $rows[$i];
}

// while - keep going until something stops you
$stmt = $pdo->query('SELECT name, marks FROM students ORDER BY marks DESC');
while ($row = $stmt->fetch()) {
    echo htmlspecialchars($row['name']) . ': ' . (int) $row['marks'] . "<br>";
}

// do-while - body runs at least once
$attempt = 0;
do {
    $attempt++;
    $ok = tryToSendEmail();
} while (!$ok && $attempt < 3);

// range() often removes the need for a manual counter
foreach (range(2026, 2020) as $year) {
    echo "<option>$year</option>";
}
Notes
  • An infinite loop in a web script is not harmless. It will hold a PHP process and eat CPU until max_execution_time kills it, and on shared hosting that can get your account suspended. When writing a while whose ending condition is not obvious, add a safety counter that breaks out after a sane number of iterations.

break and continue, Including the Numbered Forms

break leaves the loop entirely. continue skips the rest of the current iteration and moves to the next one. Both are ordinary tools, not signs of bad code — a continue that skips invalid rows at the top of a loop is the same readability win as a guard clause in a function.

The part people do not know is that both accept a number. In nested loops, plain break only leaves the innermost loop; break 2 leaves two levels. This saves the awkward "set a flag, then check the flag in the outer loop's condition" dance that nested searches otherwise need.

The same applies to continue 2, which abandons the current inner loop and moves the outer loop on to its next iteration. It is exactly what you want when validating a grid of data and one bad cell means the whole row should be skipped.

Use the numbered forms sparingly, though. break 3 requires the reader to count nesting levels correctly, and if you need it, that is often a hint that the inner loops belong in their own function — where a plain return does the job and says more.

Example
<?php
$numbers = [4, 7, 0, 9, 12, 3];

// continue: skip what you cannot use
$total = 0;
foreach ($numbers as $n) {
    if ($n === 0) {
        continue;          // skip zeros, keep going
    }
    $total += 100 / $n;
}

// break: stop as soon as you have the answer
$firstBig = null;
foreach ($numbers as $n) {
    if ($n > 8) {
        $firstBig = $n;
        break;             // no point looking further
    }
}
echo $firstBig;            // 9

// Nested search: break 2 leaves both loops
$grid = [[1, 2, 3], [4, 5, 6], [7, 8, 9]];
$found = null;
foreach ($grid as $r => $row) {
    foreach ($row as $c => $cell) {
        if ($cell === 5) {
            $found = "row $r, column $c";
            break 2;
        }
    }
}
echo $found;               // "row 1, column 1"

// continue 2: one bad cell skips the whole row
foreach ($grid as $row) {
    foreach ($row as $cell) {
        if (!is_int($cell)) {
            continue 2;    // move to the next row
        }
    }
    // only fully valid rows reach here
}
Notes
  • PHP has no goto-style loop labels like Java's break outer;. The numeric form is the whole feature, which is another reason to keep nesting shallow — a number is much easier to get wrong than a name.

Never Put a Database Query Inside a Loop

This is the single most expensive mistake beginners make with loops, and it is worth its own section because it does not look like a mistake. You fetch a list of orders, loop over them, and inside the loop you run one more query to get each order's customer name. It works perfectly on your laptop with five test rows.

Then real data arrives. A hundred orders means one hundred and one queries — the original one plus one per row. This pattern is known as the N+1 query problem, and it is the usual reason a page that felt instant in development takes eight seconds in production. Each query is fast; it is the round trip to the database, repeated a hundred times, that costs you.

The fix is to fetch everything you need in one query. Either use a SQL JOIN so the database returns customer names alongside orders, or collect the ids first and fetch them all with a single WHERE id IN (...), then index that result by id so your loop can look each one up from memory.

The same principle applies to file operations, HTTP requests and any other slow operation. Inside a loop, ask yourself: is this doing work that touches the disk or the network? If so, can it be done once outside the loop instead? That one question is the difference between a page that scales and one that does not.

Example
<?php
// WRONG - one query, then one more per row
$orders = $pdo->query('SELECT id, customer_id, total FROM orders')->fetchAll();
foreach ($orders as $order) {
    $stmt = $pdo->prepare('SELECT name FROM customers WHERE id = ?');
    $stmt->execute([$order['customer_id']]);
    $customer = $stmt->fetch();
    echo $customer['name'] . ' - ' . $order['total'] . '<br>';
}

// RIGHT (option 1) - let SQL do the joining, one query total
$sql = 'SELECT o.id, o.total, c.name
        FROM orders o
        JOIN customers c ON c.id = o.customer_id';
foreach ($pdo->query($sql) as $row) {
    echo htmlspecialchars($row['name']) . ' - ' . $row['total'] . '<br>';
}

// RIGHT (option 2) - two queries, then look up in memory
$orders = $pdo->query('SELECT id, customer_id, total FROM orders')->fetchAll();
$ids    = array_unique(array_column($orders, 'customer_id'));

if ($ids !== []) {
    $marks = implode(',', array_fill(0, count($ids), '?'));
    $stmt  = $pdo->prepare("SELECT id, name FROM customers WHERE id IN ($marks)");
    $stmt->execute(array_values($ids));
    $names = array_column($stmt->fetchAll(), 'name', 'id');

    foreach ($orders as $order) {
        echo htmlspecialchars($names[$order['customer_id']] ?? 'Unknown');
        echo ' - ' . $order['total'] . '<br>';
    }
}
Notes
  • Note how the IN list is built: a placeholder ? is generated for each id and the values are bound separately. Never paste the ids straight into the SQL string, even when they came from your own database a moment ago — building queries by concatenation is the habit that leads to injection, and habits are what you fall back on under deadline pressure.

Loops in Templates, and Small Practical Touches

Inside HTML, use the alternative syntax — foreach (...): closed by endforeach; — for the same reason as with conditionals: a named closing keyword is far easier to match up than a lone brace surrounded by markup.

Two small techniques come up constantly in listing pages. The first is a row counter, for serial numbers or for alternating row colours; $index % 2 gives you the striping and the key from foreach gives you the number. The second is handling the empty case: a table with headers and no rows looks broken, so check for an empty array before you start and print a friendly message instead.

Do not forget escaping inside loops. Every value you print from the database goes through htmlspecialchars(), because that data was typed by a user at some earlier point. It is easy to escape carefully on a single-value page and then forget entirely once you are inside a loop, and attackers look precisely for the page where a stored value gets echoed unescaped.

Finally, keep loops in templates thin. A loop that prints markup is fine. A loop that also decides business rules, calls functions with side effects and runs queries has outgrown the template; move that work into a function above the HTML and let the template display the result.

Example
<?php
$students = [
    ['name' => 'Ananya', 'marks' => 91],
    ['name' => 'Ravi',   'marks' => 78],
];
?>
<?php if ($students === []): ?>
  <p>No students have registered yet.</p>
<?php else: ?>
<table>
  <thead>
    <tr><th>#</th><th>Name</th><th>Marks</th></tr>
  </thead>
  <tbody>
  <?php foreach ($students as $i => $student): ?>
    <tr class="<?= $i % 2 === 0 ? 'even' : 'odd' ?>">
      <td><?= $i + 1 ?></td>
      <td><?= htmlspecialchars($student['name']) ?></td>
      <td><?= (int) $student['marks'] ?></td>
    </tr>
  <?php endforeach; ?>
  </tbody>
</table>
<?php endif; ?>
Notes
  • <?= (int) $student['marks'] ?> casts before printing. For a value you know must be a number, casting is a second line of defence that costs nothing — even if something unexpected reached that field, an integer cannot carry a script tag.
Ask AI