Search by

wshafer1 / phpstan-extended-rules

wshafer

Extra PHPStan rules for keeping code flat and readable

Package info

gitlab.com/wshafer1/phpstan-extended-rules

Issues

Type:phpstan-extension

pkg:composer/wshafer1/phpstan-extended-rules

Statistics

Installs: 25

Dependents: 1

Suggesters: 0

Stars: 0

0.2.0 2026-10-09 10:53 UTC

This package is auto-updated.

Last update: 2026-10-09 16:55:09 UTC


README

Extra PHPStan rules for keeping code flat and readable.

Requirements

  • PHP 8.2+
  • PHPStan 2.3+

Installation

composer require --dev wshafer1/phpstan-extended-rules

Configuration

With phpstan/extension-installer (recommended)

If your project uses phpstan/extension-installer, the rules are registered automatically. Nothing else to do.

If you don't have it yet:

composer require --dev phpstan/extension-installer

Composer will ask whether to trust the plugin. Answer yes, which adds this to your composer.json:

"config": {
    "allow-plugins": {
        "phpstan/extension-installer": true
    }
}

Without the installer

Include the rule set in your phpstan.neon (or phpstan.neon.dist):

includes:
    - vendor/wshafer1/phpstan-extended-rules/rules.neon

Enabling individual rules

To pick rules one at a time instead of loading the whole set, skip the include above and register them under rules: in your own config:

rules:
    - Wshafer1\PhpStanExtendedRules\Rules\NestedIf\NoNestedIfRule
    - Wshafer1\PhpStanExtendedRules\Rules\NestedForeach\NoNestedForeachRule
    - Wshafer1\PhpStanExtendedRules\Rules\NestedWhile\NoNestedWhileRule
    - Wshafer1\PhpStanExtendedRules\Rules\NestedFor\NoNestedForRule
    - Wshafer1\PhpStanExtendedRules\Rules\NestedDo\NoNestedDoRule

Rules

No nested if

Wshafer1\PhpStanExtendedRules\Rules\NestedIf\NoNestedIfRule

Reports an if that sits inside the body, elseif or else branch of another if, inside a case of a switch, or inside the body of a do/while. Nested conditions are harder to read and test. There are two usual fixes: invert the outer condition into a guard clause that returns (or continues) early, or pull the inner check out into its own method so it gets a name. Either way each method keeps a single level of branching. Inside a do/while even a guard clause is reported, so extract the loop body into its own method instead.

// Invalid
if ($user->isActive()) {
    if ($user->hasRole('admin')) {   // Nested if found.
        grantAccess($user);
    }
}

// Also invalid: an if anywhere inside an if, even within a loop, try or switch
if ($items !== []) {
    foreach ($items as $item) {
        if ($item->isValid()) {      // Nested if found.
            process($item);
        }
    }
}

// Also invalid: an if inside a switch case
switch ($event->type) {
    case 'signup':
        if ($event->user->isVerified()) {   // If inside a switch found.
            welcome($event->user);
        }
        break;
}

// Also invalid: an if inside a do-while body, guard clauses included
do {
    $line = $reader->readLine();
    if ($line === '') {                     // If inside a do-while found.
        continue;
    }
    store($line);
} while (!$reader->eof());

// Also invalid: an if as the only statement in an else (use elseif instead)
if ($a) {
    one();
} else {
    if ($b) {                        // Nested if found.
        two();
    }
}

// Correct: an if/elseif/else chain is a single level
if ($a) {
    one();
} elseif ($b) {
    two();
} else {
    three();
}

// Correct: invert the outer check into a guard clause and return early
if (!$user->isActive()) {
    return;
}

if ($user->hasRole('admin')) {
    grantAccess($user);
}

// Correct: inside a loop, continue early instead
foreach ($items as $item) {
    if (!$item->isValid()) {
        continue;
    }

    process($item);
}

// Correct: extract the inner check into its own method
if ($user->isActive()) {
    $this->grantAccessToAdmin($user);
}

private function grantAccessToAdmin(User $user): void
{
    if ($user->hasRole('admin')) {
        grantAccess($user);
    }
}

What it does not report:

  • An if inside a closure, arrow function, named function or class declared within the outer if, switch or do/while; each of those starts fresh.
  • Ternaries, match and other expressions; only if statements count. A match arm is an expression, so an if can only end up there through a closure, which starts fresh.

Each nested if is reported once, at its own line, against the closest enclosing if, switch or do/while. So if → switch → if is one error ("If inside a switch found."), not two. Loops other than do/while are looked through, so do → foreach → if is "If inside a do-while found.". For deeper nesting, every level is reported.

Error output, depending on the enclosing statement:

Nested if found.
💡 Invert the outer condition and return (or continue) early, or extract the inner check into its own method.

If inside a switch found.
💡 Extract the case body into its own method.

If inside a do-while found.
💡 Extract the loop body into its own method.

All three share one identifier, so a single ignore covers them.

Identifier: extendedRules.nestedIf

To silence a specific occurrence:

// @phpstan-ignore extendedRules.nestedIf
if ($b) {

Or ignore it across a path in your config:

parameters:
    ignoreErrors:
        -
            identifier: extendedRules.nestedIf
            path: src/Legacy/*

No nested foreach

Wshafer1\PhpStanExtendedRules\Rules\NestedForeach\NoNestedForeachRule

Reports a foreach that sits inside the body of another foreach, the body of a for, while or do/while, or the body, elseif or else branch of an if. A loop inside another loop hides what the inner loop is for and is an easy way to end up with quadratic work. A loop inside a condition is a second level of nesting that is usually better as a guard clause. The usual fixes: pull the foreach out into its own method so it gets a name, index the inner data by key before the outer loop so each lookup is direct, or invert the if and return early.

// Invalid
foreach ($orders as $order) {
    foreach ($order->lines as $line) {     // Nested foreach found.
        $total += $line->price;
    }
}

// Invalid: a foreach inside an if, elseif or else
if ($order->isPaid()) {
    foreach ($order->lines as $line) {     // Foreach inside an if found.
        ship($line);
    }
}

// Invalid: a foreach inside a while
while ($batch = $queue->next()) {
    foreach ($batch as $job) {             // Foreach inside a while found.
        run($job);
    }
}

// Also invalid: through other statements such as try, switch or a block
foreach ($users as $user) {
    try {
        foreach ($roles as $role) {        // Nested foreach found.
            assign($user, $role);
        }
    } catch (Throwable) {
    }
}

// Correct: invert the condition and return early
if (!$order->isPaid()) {
    return;
}

foreach ($order->lines as $line) {
    ship($line);
}

// Correct: extract the inner loop into its own method
foreach ($orders as $order) {
    $total += $this->orderTotal($order);
}

private function orderTotal(Order $order): int
{
    $total = 0;

    foreach ($order->lines as $line) {
        $total += $line->price;
    }

    return $total;
}

// Correct: index the inner data before the outer loop
$customersById = array_column($customers, null, 'id');

foreach ($orders as $order) {
    $customer = $customersById[$order->customerId];
}

What it does not report:

  • A foreach inside a closure, arrow function, named function or class declared within the outer statement; each of those starts fresh.
  • Callbacks such as array_map; only foreach statements count.
  • Anything inside a foreach other than another foreach: an if in a foreach body is fine (the guard-clause pattern), and a for, while or do/while there is left to its own rule.

Each nested foreach is reported once, at its own line, against the closest enclosing foreach, for, while, do/while or if. So foreach → if → foreach is one error ("Foreach inside an if found."), not two. For deeper nesting, every level is reported.

Error output, depending on the enclosing statement:

Nested foreach found.
💡 Extract the inner loop into its own method, or index the inner data by key before the outer loop.

Foreach inside an if found.
💡 Invert the condition and return (or continue) early, or extract the foreach into its own method.

Foreach inside a for found.
💡 Extract the foreach into its own method.

Foreach inside a while found.
💡 Extract the foreach into its own method.

Foreach inside a do-while found.
💡 Extract the foreach into its own method.

All five share one identifier, so a single ignore covers them.

Identifier: extendedRules.nestedForeach

To silence a specific occurrence:

// @phpstan-ignore extendedRules.nestedForeach
foreach ($row as $cell) {

Or ignore it across a path in your config:

parameters:
    ignoreErrors:
        -
            identifier: extendedRules.nestedForeach
            path: src/Legacy/*

No nested while

Wshafer1\PhpStanExtendedRules\Rules\NestedWhile\NoNestedWhileRule

Reports a while that sits inside the body of another while, the body of a for, foreach or do/while, or the body, elseif or else branch of an if. It is the while counterpart of the nested foreach rule, for the same reasons: a loop inside a loop hides what the inner one is for, and a loop inside a condition is better as a guard clause. The usual fixes: pull the while out into its own method so it gets a name, or invert the if and return early.

// Invalid
while ($page = $api->nextPage()) {
    while ($item = $page->next()) {          // Nested while found.
        import($item);
    }
}

// Invalid: a while inside a foreach
foreach ($feeds as $feed) {
    while ($entry = $feed->read()) {         // While inside a foreach found.
        store($entry);
    }
}

// Invalid: a while inside an if, elseif or else
if ($queue->isOpen()) {
    while ($job = $queue->pop()) {           // While inside an if found.
        run($job);
    }
}

// Correct: invert the condition and return early
if (!$queue->isOpen()) {
    return;
}

while ($job = $queue->pop()) {
    run($job);
}

// Correct: extract the inner loop into its own method
foreach ($feeds as $feed) {
    $this->storeEntries($feed);
}

private function storeEntries(Feed $feed): void
{
    while ($entry = $feed->read()) {
        store($entry);
    }
}

What it does not report:

  • A while inside a closure, arrow function, named function or class declared within the outer statement; each of those starts fresh.
  • do/while loops themselves; those are left to the nested do-while rule.
  • Anything inside a while other than another while: an if, for or foreach in a while body is left to the other rules (the if is the guard-clause pattern).

Each nested while is reported once, at its own line, against the closest enclosing while, for, foreach, do/while or if. So while → if → while is one error ("While inside an if found."), not two. For deeper nesting, every level is reported.

Error output, depending on the enclosing statement:

Nested while found.
💡 Extract the inner loop into its own method.

While inside a for found.
💡 Extract the while into its own method.

While inside a foreach found.
💡 Extract the while into its own method.

While inside a do-while found.
💡 Extract the while into its own method.

While inside an if found.
💡 Invert the condition and return (or continue) early, or extract the while into its own method.

All five share one identifier, so a single ignore covers them.

Identifier: extendedRules.nestedWhile

To silence a specific occurrence:

// @phpstan-ignore extendedRules.nestedWhile
while ($b) {

Or ignore it across a path in your config:

parameters:
    ignoreErrors:
        -
            identifier: extendedRules.nestedWhile
            path: src/Legacy/*

No nested for

Wshafer1\PhpStanExtendedRules\Rules\NestedFor\NoNestedForRule

Reports a for that sits inside the body of another for, the body of a foreach, while or do/while, or the body, elseif or else branch of an if. It is the for counterpart of the nested foreach and while rules, for the same reasons: a loop inside a loop hides what the inner one is for and is an easy way to end up with quadratic work, and a loop inside a condition is better as a guard clause. The usual fixes: pull the for out into its own method so it gets a name, or invert the if and return early.

// Invalid
for ($y = 0; $y < $height; $y++) {
    for ($x = 0; $x < $width; $x++) {        // Nested for found.
        $grid[$y][$x] = 0;
    }
}

// Invalid: a for inside a foreach
foreach ($matrices as $matrix) {
    for ($i = 0; $i < $matrix->size; $i++) { // For inside a foreach found.
        $trace += $matrix->get($i, $i);
    }
}

// Invalid: a for inside a while
while ($chunk = $reader->next()) {
    for ($i = 0; $i < strlen($chunk); $i++) { // For inside a while found.
        $checksum ^= ord($chunk[$i]);
    }
}

// Invalid: a for inside an if, elseif or else
if ($retries > 0) {
    for ($i = 0; $i < $retries; $i++) {      // For inside an if found.
        attempt();
    }
}

// Correct: invert the condition and return early
if ($retries <= 0) {
    return;
}

for ($i = 0; $i < $retries; $i++) {
    attempt();
}

// Correct: extract the inner loop into its own method
for ($y = 0; $y < $height; $y++) {
    $grid[$y] = $this->emptyRow($width);
}

private function emptyRow(int $width): array
{
    $row = [];

    for ($x = 0; $x < $width; $x++) {
        $row[$x] = 0;
    }

    return $row;
}

What it does not report:

  • A for inside a closure, arrow function, named function or class declared within the outer statement; each of those starts fresh.
  • do/while loops themselves; those are left to the nested do-while rule.
  • Anything inside a for other than another for: an if, foreach or while in a for body is fine as far as this rule goes (the if is the guard-clause pattern).

Each nested for is reported once, at its own line, against the closest enclosing for, foreach, while, do/while or if. So for → if → for is one error ("For inside an if found."), not two. For deeper nesting, every level is reported.

Error output, depending on the enclosing statement:

Nested for found.
💡 Extract the inner loop into its own method.

For inside a foreach found.
💡 Extract the for into its own method.

For inside a while found.
💡 Extract the for into its own method.

For inside a do-while found.
💡 Extract the for into its own method.

For inside an if found.
💡 Invert the condition and return (or continue) early, or extract the for into its own method.

All five share one identifier, so a single ignore covers them.

Identifier: extendedRules.nestedFor

To silence a specific occurrence:

// @phpstan-ignore extendedRules.nestedFor
for ($x = 0; $x < $width; $x++) {

Or ignore it across a path in your config:

parameters:
    ignoreErrors:
        -
            identifier: extendedRules.nestedFor
            path: src/Legacy/*

No nested do-while

Wshafer1\PhpStanExtendedRules\Rules\NestedDo\NoNestedDoRule

Reports a do/while that sits inside the body of another do/while, the body of a for, foreach or while, or the body, elseif or else branch of an if. It is the do/while counterpart of the other nested loop rules, for the same reasons: a loop inside a loop hides what the inner one is for, and a loop inside a condition is better as a guard clause. The usual fixes: pull the do/while out into its own method so it gets a name, or invert the if and return early.

// Invalid
do {
    do {                                     // Nested do-while found.
        $line = $stream->readLine();
    } while ($line === '');
} while (!$stream->eof());

// Invalid: a do-while inside a for, foreach or while
foreach ($hosts as $host) {
    do {                                     // Do-while inside a foreach found.
        $ok = ping($host);
    } while (!$ok && --$attempts > 0);
}

// Invalid: a do-while inside an if, elseif or else
if ($client->isConnected()) {
    do {                                     // Do-while inside an if found.
        $message = $client->receive();
    } while ($message !== null);
}

// Correct: invert the condition and return early
if (!$client->isConnected()) {
    return;
}

do {
    $message = $client->receive();
} while ($message !== null);

// Correct: extract the inner loop into its own method
foreach ($hosts as $host) {
    $this->pingWithRetries($host, $attempts);
}

private function pingWithRetries(string $host, int $attempts): void
{
    do {
        $ok = ping($host);
    } while (!$ok && --$attempts > 0);
}

What it does not report:

  • A do/while inside a closure, arrow function, named function or class declared within the outer statement, including a closure in the while (...) condition; each of those starts fresh.
  • Anything inside a do/while other than another do/while: an if, for, foreach or while in a do body is reported by its own rule.

Each nested do/while is reported once, at its own line (the do), against the closest enclosing do/while, for, foreach, while or if. So do → if → do is one error ("Do-while inside an if found."), not two. For deeper nesting, every level is reported.

Error output, depending on the enclosing statement:

Nested do-while found.
💡 Extract the inner loop into its own method.

Do-while inside a for found.
💡 Extract the do-while into its own method.

Do-while inside a foreach found.
💡 Extract the do-while into its own method.

Do-while inside a while found.
💡 Extract the do-while into its own method.

Do-while inside an if found.
💡 Invert the condition and return (or continue) early, or extract the do-while into its own method.

All five share one identifier, so a single ignore covers them.

Identifier: extendedRules.nestedDo

To silence a specific occurrence:

// @phpstan-ignore extendedRules.nestedDo
do {

Or ignore it across a path in your config:

parameters:
    ignoreErrors:
        -
            identifier: extendedRules.nestedDo
            path: src/Legacy/*

Development

composer install
composer test

License

MIT