sanmai / phpstan-rules
Custom PHPStan rules for enforcing code quality standards
Fund package maintenance!
sanmai
Installs: 1 446
Dependents: 6
Suggesters: 0
Security: 0
Stars: 1
Watchers: 0
Forks: 0
Open Issues: 0
Type:phpstan-extension
Requires
- php: ^8.2
- phpstan/phpstan: ^2.0
Requires (Dev)
- ergebnis/composer-normalize: ^2.8
- friendsofphp/php-cs-fixer: ^3.17
- infection/infection: >=0.10.5
- league/pipeline: ^0.3 || ^1.0
- php-coveralls/php-coveralls: ^2.4.1
- phpstan/phpstan-deprecation-rules: ^2.0
- phpunit/phpunit: ^9.5 || ^10.0
- sanmai/later: ^0.1.7
- sanmai/pipeline: ^6.22 || ^7.0
- vimeo/psalm: >=2
Suggests
- phpstan/extension-installer: Allows automatic registration of PHPStan extensions
README
A collection of opinionated PHPStan rules focused on enforcing functional programming patterns and reducing complexity. These rules are tailored specifically to the kind of code LLMs are prone to produce.
Philosophy
These rules encourage:
- Functional programming patterns over imperative nested structures
- Early returns and guard clauses for better readability
- Reduced cyclomatic complexity through flatter code structures
- Explicit code flow that's easier to test and maintain
These principles align well with libraries like sanmai/pipeline
that provide functional programming patterns as alternatives to nested loops.
My rules are designed to work together without creating "whack-a-mole" scenarios. When multiple rules could apply to the same code pattern, they all trigger simultaneously, showing you the complete picture upfront. For example, a function ending with if-else will trigger all matching rules, guiding you directly to the guard clause rather than making you fix one issue only to discover another. This approach helps you fix all violations in a single pass.
Installation
composer require --dev sanmai/phpstan-rules
If you also install phpstan/extension-installer
then you're all set!
composer require --dev phpstan/extension-installer
Manual Installation
If you don't want to use phpstan/extension-installer
, include the extension in your project's phpstan.neon
:
includes: - vendor/sanmai/phpstan-rules/extension.neon
Rules
NoNestedLoopsRule
Prevents nested loops within the same function scope.
This rule encourages extracting nested loops into separate methods or using functional approaches like array_map()
, array_filter()
, or the sanmai/pipeline
library.
Bad
foreach ($users as $user) { foreach ($user->getPosts() as $post) { // Error: Nested loops are not allowed if ($post->isPublished()) { $titles[] = $post->getTitle(); } } }
Good - Using sanmai/pipeline
use function Pipeline\take; $titles = take($users) ->map(fn($user) => yield from $user->getPosts()) ->filter(fn($post) => $post->isPublished()) ->cast(fn($post) => $post->getTitle()) ->toList();
NoNestedIfStatementsRule
Discourages simple nested if statements without else branches.
This rule promotes combining conditions with logical operators or using guard clauses for flatter code structure.
Bad
if ($user->isActive()) { if ($user->hasPermission('edit')) { // Error: Nested if statements should be avoided $this->grantAccess(); } }
Good - Combined conditions
if ($user->isActive() && $user->hasPermission('edit')) { $this->grantAccess(); }
Good - Guard clauses
if (!$user->isActive()) { return; } if (!$user->hasPermission('edit')) { return; } $this->grantAccess();
RequireGuardClausesRule
Enforces the use of guard clauses in loops instead of wrapping the main logic in if statements.
This rule encourages early returns/continues to reduce nesting and improve readability.
Exception: Loops where the if statement contains only return
, yield
, yield from
, or throw
statements are allowed, as these are common patterns for filtering/searching operations.
Bad - Loop with only if
foreach ($items as $item) { if ($item->isValid()) { // Error: Use guard clauses $item->process(); $item->save(); } }
Good - Guard clause
foreach ($items as $item) { if (!$item->isValid()) { continue; } $item->process(); $item->save(); }
Good - If with other statements (allowed)
foreach ($items as $item) { if (count($buffer) >= $limit) { // OK: Loop has more than just the if array_shift($buffer); } $buffer[] = $item; }
NoElseRule
Forbids the use of else
statements.
This rule enforces the use of early returns and guard clauses instead of else
branches, leading to flatter and more readable code.
Bad
if ($user->isActive()) { return $user->getName(); } else { // Error: Else statements are not allowed return 'Guest'; }
Good
if (!$user->isActive()) { return 'Guest'; } return $user->getName();
NoEmptyRule
Forbids the use of the empty()
function (except on nullable arrays).
This rule encourages more explicit checks instead of the ambiguous empty()
function, which can hide bugs and make code harder to understand. The only exception is nullable arrays (?array
) where empty()
provides a cleaner syntax than $items === null || $items === []
.
Bad
if (empty($data)) { // Error: The empty() function is not allowed return null; } if (empty($items)) { // Error: Use $items === [] instead return 'No items'; }
Good
// Be explicit about what you're checking if ($data === null) { return null; } // For arrays if ($items === []) { return 'No items'; } // Exception: nullable arrays are allowed function process(?array $items): void { if (empty($items)) { // OK: cleaner than ($items === null || $items === []) return; } // process items... }
NoEmptyOnStringsRule
Forbids the use of empty()
on string types with a specific warning about the '0'
gotcha.
This rule specifically targets string types and mixed types (which could contain strings) to warn about the dangerous behavior where empty('0')
returns true
. This has caused real bugs in production systems where users couldn't search for "0" or use "0" as a valid input.
Bad
function validateTag(string $tag): void { if (empty($tag)) { // Error: empty() on strings - empty('0') returns true! throw new \InvalidArgumentException('Tag cannot be empty'); } } // This will throw an exception even though '0' is a valid tag! validateTag('0');
Good
function validateTag(string $tag): void { if ($tag === '') { // Explicit check that doesn't treat '0' as empty throw new \InvalidArgumentException('Tag cannot be empty'); } } // For nullable strings, be explicit about both conditions function process(?string $input): void { if ($input === null || $input === '') { return; } // process input... }
RequireGuardClausesInFunctionsRule
Requires guard clauses in functions/methods that end with a single large if statement.
This rule detects functions with void return types (or no return type) that end with a single if
statement containing the main logic. These should be refactored to use guard clauses with early returns, reducing nesting and improving readability.
Bad
function processData(): void { $this->initialize(); if ($this->isValid()) { // Error: Use guard clause instead $this->transform(); $this->validate(); $this->save(); $this->notify(); } }
Good
function processData(): void { $this->initialize(); if (!$this->isValid()) { return; } $this->transform(); $this->validate(); $this->save(); $this->notify(); }
This rule only applies to functions with void
return type or no return type. Functions that declare specific return types are exempt, for now.
NoCountZeroComparisonRule
Forbids comparing count()
with 0.
This rule encourages using direct array comparisons (=== []
or !== []
) instead of counting elements, which is more efficient and clearer.
Bad
if (count($items) === 0) { // Error: Avoid comparing count() with 0 return 'No items'; } if (count($items) > 0) { // Error: Avoid comparing count() with 0 process($items); }
Good
if ($items === []) { return 'No items'; } if ($items !== []) { process($items); } // Other count comparisons are fine if (count($items) === 1) { return 'Single item'; }
NoFinalClassesRule
Prevents the use of final
keyword on classes.
This rule discourages final classes as they often create more problems than they solve, especially for testing and mocking. The @final
annotation provides the same benefits by preventing extension via static analysis without the runtime restrictions that interfere with testing and lead to indirection hell.
Bad
final class UserService { public function getUser(int $id): User { // ... } }
Good
class UserService { public function getUser(int $id): User { // ... } } // Or with @final annotation /** * @final */ class UserService { public function getUser(int $id): User { // ... } // Private methods are not prohibited private function updateUsers(): void { } // Just as final methods are still discretionary final public function getAll(): iterable { } }
This rule only applies to classes. To turn off this rule for specific classes, you can use PHPStan's annotation:
/** * @phpstan-ignore sanmai.noFinalClasses */ final class SpecialCaseThatMustBeFinal { // This final class will be ignored }
If you'd rather use dg/bypass-finals
for testing, you can turn off this rule entirely by adding the following to your phpstan.neon
:
parameters: ignoreErrors: - identifier: sanmai.noFinalClasses
Ignoring Rules
Please refer to the PHPStan documentation.
Contributing
Found a bug or have a suggestion? Please open an issue.