wshafer1 / phpstan-extended-rules
Extra PHPStan rules for keeping code flat and readable
Package info
gitlab.com/wshafer1/phpstan-extended-rules
Type:phpstan-extension
pkg:composer/wshafer1/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 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
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
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
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
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
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
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
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
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/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/*
Development
composer install
composer test
License
MIT