Search by

blazon / phpstan-extended-rules

wshafer

Extra PHPStan rules for keeping code flat and readable

Package info

gitlab.com/blazon/phpstan-extended-rules

Issues

Type:phpstan-extension

pkg:composer/blazon/phpstan-extended-rules

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 0

0.4.1 2026-11-03 09:35 UTC

This package is auto-updated.

Last update: 2026-11-03 16:44:37 UTC


README

Extra PHPStan rules for keeping code flat and readable.

Requirements

  • PHP 8.2+
  • PHPStan 2.3+

Installation

composer require --dev blazon/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/blazon/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:
    - Blazon\PhpStanExtendedRules\Rules\NestedIf\NoNestedIfRule
    - Blazon\PhpStanExtendedRules\Rules\NestedForeach\NoNestedForeachRule
    - Blazon\PhpStanExtendedRules\Rules\NestedWhile\NoNestedWhileRule
    - Blazon\PhpStanExtendedRules\Rules\NestedFor\NoNestedForRule
    - Blazon\PhpStanExtendedRules\Rules\NestedDo\NoNestedDoRule
    - Blazon\PhpStanExtendedRules\Rules\NestedTry\NoNestedTryRule

Rules

No nested if

Blazon\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

Blazon\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

Blazon\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

Blazon\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

Blazon\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/*

No nested try

Blazon\PhpStanExtendedRules\Rules\NestedTry\NoNestedTryRule

Reports a try that sits inside the body, a catch or the finally of another try. A try inside a loop, if or switch is fine on its own; it is only reported when an outer try encloses it, however many of those sit in between. Nested error handling is hard to follow, since it is unclear which catch handles what. Pull the inner try out into its own method so each one has a single job.

// Invalid
try {
    try {                                  // Nested try found.
        $connection->send($payload);
    } catch (TimeoutException) {
        retry();
    }
} catch (Throwable $e) {
    log($e);
}

// Also invalid: through a loop or if, or inside a catch or finally
try {
    foreach ($items as $item) {
        try {                              // Nested try found.
            process($item);
        } catch (InvalidItem) {
        }
    }
} finally {
    cleanup();
}

// Correct: extract the inner try into its own method
try {
    $this->send($connection, $payload);
} catch (Throwable $e) {
    log($e);
}

private function send(Connection $connection, Payload $payload): void
{
    try {
        $connection->send($payload);
    } catch (TimeoutException) {
        retry();
    }
}

// Correct: a try inside a loop or if, with no outer try
foreach ($items as $item) {
    try {
        process($item);
    } catch (InvalidItem) {
    }
}

What it does not report:

  • A try inside a closure, arrow function, named function or class declared within the outer try; each of those starts fresh.
  • Sequential try blocks that are not inside one another.

Each nested try is reported once, at its own line, against the closest enclosing try. For deeper nesting, every level is reported.

Error output:

Nested try found.
💡 Extract the inner try into its own method.

Identifier: extendedRules.nestedTry

To silence a specific occurrence:

// @phpstan-ignore extendedRules.nestedTry
try {

Or ignore it across a path in your config:

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

Development

composer install
composer test

License

MIT