Loop Runs At Most Once
ID |
php.loop_runs_at_most_once |
Severity |
high |
Remediation Complexity |
medium |
Remediation Risk |
low |
Remediation Effort |
medium |
Resource |
Reliability |
Language |
Php |
Tags |
control-flow, loop, reliability |
Description
Reports a for, foreach, while or do-while loop whose body ends in an unconditional break,
return or throw.
The finding sits on the jump, which is the statement to move or delete. Only the last statement of the body counts, so a guarded jump is never reported — see Configuration.
Rationale
<?php
function sendAll(array $rows, Mailer $mailer): void
{
foreach ($rows as $row) {
$mailer->send($row);
break; // FLAW — only the first row is ever sent
}
}
The loop cannot iterate as written: the first pass always transfers control away. A construct that reads like "process every element" processes exactly one, and no test that feeds it a single row will notice.
The usual origin is a jump written one nesting level too high — it was meant to sit inside an if
and ended up as a direct statement of the body. The return variant is the same mistake in the
find-first idiom:
foreach ($rows as $row) {
return $row->render(); // FLAW — returns the first row, whatever the query
}
Remediation
Guard the jump with the condition it was meant to test, so the loop searches instead of taking the first element:
<?php
function findFirst(array $rows, string $query): ?Row
{
foreach ($rows as $row) {
if ($row->matches($query)) {
return $row;
}
}
return null;
}
If the code really does only need the first element, say so and drop the loop:
$first = $rows[array_key_first($rows)] ?? null;
if ($first !== null) {
$mailer->send($first);
}
Configuration
This rule has no configurable properties. Several shapes are excluded by construction.
A guarded jump. Only the last statement of the body is examined, and only when it is a direct
statement of the body. A jump inside an if, a switch, a try or a nested loop is never
reported, because in those shapes the body’s last statement is the enclosing construct rather than
the jump. A trailing bare block is descended into.
A break inside a nested switch. In PHP, break binds to the innermost enclosing switch
or loop, so a break in a case leaves the switch and the loop keeps going. That shape cannot be
reported here: when the body ends in a switch, the body’s last statement is the switch.
foreach ($rows as $row) { // OK — the break leaves the switch, not the loop
switch (kindOf($row)) {
case 'send':
$mailer->send($row);
break;
default:
break;
}
}
A loop with a continue of its own. A continue anywhere in the body that belongs to this loop
suppresses the report, guarded or not: it is direct evidence that the loop was written to run more
than once, so the trailing jump is the deliberate end of the find-first idiom.
foreach ($rows as $row) { // OK — the continue proves the loop iterates
if (!$row->matches($query)) {
continue;
}
return $row;
}
A continue N; carrying a level may target this loop from inside a nested one, so any levelled
continue in the body suppresses the report too. A trailing break N; is reported: it still ends
this loop on the first iteration.
A trailing continue is not reported at all. It does not cut the loop short — control was about to
reach the end of the body anyway — so it is a different defect from the one this rule owns.