blazon / phpstan-extended-rules
Extra PHPStan rules for keeping code flat and readable
Package info
gitlab.com/blazon/phpstan-extended-rules
Type:phpstan-extension
pkg:composer/blazon/phpstan-extended-rules
Requires
- php: ^8.2
- phpstan/phpstan: ^2.3
Requires (Dev)
- phpunit/phpunit: ^11.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
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
ifinside a closure, arrow function, named function or class declared within the outerif,switchordo/while; each of those starts fresh. - Ternaries,
matchand other expressions; onlyifstatements count. Amatcharm is an expression, so anifcan 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
foreachinside a closure, arrow function, named function or class declared within the outer statement; each of those starts fresh. - Callbacks such as
array_map; onlyforeachstatements count. - Anything inside a
foreachother than anotherforeach: anifin aforeachbody is fine (the guard-clause pattern), and afor,whileordo/whilethere 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
whileinside a closure, arrow function, named function or class declared within the outer statement; each of those starts fresh. do/whileloops themselves; those are left to the nested do-while rule.- Anything inside a
whileother than anotherwhile: anif,fororforeachin awhilebody is left to the other rules (theifis 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
forinside a closure, arrow function, named function or class declared within the outer statement; each of those starts fresh. do/whileloops themselves; those are left to the nested do-while rule.- Anything inside a
forother than anotherfor: anif,foreachorwhilein aforbody is fine as far as this rule goes (theifis 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/whileinside a closure, arrow function, named function or class declared within the outer statement, including a closure in thewhile (...)condition; each of those starts fresh. - Anything inside a
do/whileother than anotherdo/while: anif,for,foreachorwhilein adobody 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
tryinside a closure, arrow function, named function or class declared within the outertry; each of those starts fresh. - Sequential
tryblocks 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