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.

© 2026 Xygeni. All rights reserved