haspadar / phpstan-rules
PHPStan design rules for immutability and structure
Package info
github.com/haspadar/phpstan-rules
Type:phpstan-extension
pkg:composer/haspadar/phpstan-rules
Requires
- php: ~8.3.16 || ~8.4.3 || ~8.5.0
- phpstan/phpstan: ^2.0
Requires (Dev)
- haspadar/sheriff: ^0.33
- kubawerlos/php-cs-fixer-custom-fixers: ^3.36
- slevomat/coding-standard: ^8.28
- dev-main
- v0.53.1
- v0.53.0
- v0.52.1
- v0.52.0
- v0.51.1
- v0.51.0
- v0.50.0
- v0.49.1
- v0.49.0
- v0.48.1
- v0.48.0
- v0.47.1
- v0.47.0
- v0.46.0
- v0.45.0
- v0.44.3
- v0.44.2
- v0.44.1
- v0.44.0
- v0.43.0
- v0.42.1
- v0.42.0
- v0.41.0
- v0.40.0
- v0.39.0
- v0.38.1
- v0.38.0
- v0.37.4
- v0.37.3
- v0.37.2
- v0.37.1
- v0.37.0
- v0.36.0
- v0.35.0
- v0.34.0
- v0.33.0
- v0.32.0
- v0.31.0
- v0.30.4
- v0.30.3
- v0.30.2
- v0.30.1
- v0.30.0
- v0.29.0
- v0.28.0
- v0.27.2
- v0.27.1
- v0.27.0
- v0.26.0
- v0.25.1
- v0.25.0
- v0.24.0
- v0.23.0
- v0.22.0
- v0.21.0
- v0.20.0
- v0.19.0
- v0.18.0
- v0.17.0
- v0.16.1
- v0.16.0
- v0.15.0
- v0.14.1
- v0.14.0
- v0.13.0
- v0.12.0
- v0.11.0
- v0.10.0
- v0.9.0
- v0.8.0
- v0.7.0
- v0.6.0
- v0.5.1
- v0.5.0
- v0.4.0
- v0.3.0
- v0.2.0
- v0.1.0
- dev-afferent-coupling-ignore-test-references
- dev-path-aware-remaining-rules
- dev-path-aware-naming-rules
- dev-path-aware-phpdoc-rules
- dev-path-aware-design-rules
- dev-bump-sheriff
- dev-path-aware-metric-rules
- dev-path-aware-rules
- dev-drop-named-constructor-delegation
- dev-named-constructor-delegation
- dev-boolean-argument-flag-experimental
- dev-boolean-argument-flag-rule
- dev-too-many-fields-rule
- dev-npath-complexity-rule
- dev-allow-named-constructors
- dev-remove-prohibit-long-type-alias-rule
- dev-prohibit-long-type-alias-pseudo-types
- dev-long-type-alias-pascalcase-fix
- dev-prohibit-long-type-alias
- dev-migrate-piqule-to-sheriff
- dev-prohibit-static-only-public
- dev-nested-switch-rule
- dev-if-then-throw-else-rule
- dev-improve-readme
- dev-throws-count-rule
- dev-explicit-initialization-rule
- dev-simplify-boolean-expression-rule
- dev-switch-default-rule
- dev-nested-try-depth-rule
- dev-nested-for-depth-rule
- dev-nested-if-depth-rule
- dev-multiple-variable-declarations-rule
- dev-require-ignore-reason-rule
- dev-hidden-field-rule
- dev-missing-throws-rule
- dev-no-actor-suffix-rule
- dev-fix-inner-assignment-in-for
- dev-instability-rule
- dev-lack-of-cohesion-coverage
- dev-lack-of-cohesion-core
- dev-lack-of-cohesion-rule
- dev-todo-issue-required
- dev-sync-piqule-php83
- dev-weighted-methods-rule
- dev-no-public-constants-rule
- dev-never-return-null-rule
- dev-never-accept-null-arguments-rule
- dev-forbidden-class-suffix-rule
- dev-todo-comment-rule
- dev-string-concat-rule
- dev-constant-usage-rule
- dev-unnecessary-local-rule
- dev-safe-regex-delimiter
- dev-catch-parameter-name-rule
- dev-parameter-name-rule
- dev-variable-name-rule
- dev-update-piqule-envs
- dev-no-inline-comment-rule
- dev-class-length-rule
- dev-cognitive-complexity-rule
- dev-forbid-line-comment-before-declaration
- dev-class-constant-type-hint-rule
- dev-suppress-psalm-unused-method
- dev-upgrade-piqule
- dev-no-phpdoc-for-overridden
- dev-phpdoc-missing-class
- dev-upgrade-piqule-slevomat
- dev-param-description-capital
- dev-return-description-capital-readonly
- dev-return-description-capital
- dev-phpdoc-missing-property
- dev-phpdoc-missing-method
- dev-expose-rule-parameters
- dev-atclause-order
- dev-phpdoc-capitalization
- dev-phpdoc-empty-rule
- dev-update-readme-rules
- dev-fix-sonarcloud-return-count
- dev-phpdoc-punctuation-rule
- dev-upgrade-piqule-strict-rules
- dev-modified-control-variable
- dev-inner-assignment-rule
- dev-fix-cyclomatic-checked-exception
- dev-forbid-broad-throws
- dev-forbid-broad-exception-catch
- dev-forbid-parameter-reassignment
- dev-constructor-only-initializes
- dev-no-public-static
- dev-protected-in-final
- dev-return-count-rule
- dev-mutable-exception-rule
- dev-final-class-rule
- dev-php84-85-fixture-coverage
- dev-statement-count-rule
- dev-boolean-expression-complexity-rule
- dev-coupling-between-objects-rule
- dev-cyclomatic-complexity-rule-fixes
- dev-cyclomatic-complexity-rule
- dev-remove-qulice-files-from-root
- dev-parameter-number-rule
- dev-too-many-methods-rule
- dev-file-length-rule
- dev-method-length-rule
- dev-method-lines-rule
This package is auto-updated.
Last update: 2026-05-25 06:58:53 UTC
README
Installation
composer require --dev haspadar/phpstan-rules
Then include the rules in your phpstan.neon:
includes: - vendor/haspadar/phpstan-rules/rules.neon
Rules
Metrics
| Rule | Default | Description |
|---|---|---|
MethodLengthRule |
100 | Method body must not exceed N lines |
FileLengthRule |
1000 | File must not exceed N lines |
TooManyMethodsRule |
20 | Class must not have more than N methods |
TooManyFieldsRule |
5 | Class must not have more than N fields (declared properties + promoted ctor params) |
ParameterNumberRule |
3 | Method must not have more than N parameters |
CyclomaticComplexityRule |
10 | Method cyclomatic complexity must not exceed N |
CognitiveComplexityRule |
10 | Method cognitive complexity must not exceed N (nesting is penalised) |
NPathComplexityRule |
200 | Method NPath complexity must not exceed N (Nejmeh 1988, multiplicative) |
CouplingBetweenObjectsRule |
15 | Class must not depend on more than N unique types |
BooleanExpressionComplexityRule |
3 | Method must not have more than N boolean operators in a single expression |
ClassLengthRule |
500 | Class body must not exceed N lines |
StatementCountRule |
30 | Method must not have more than N executable statements |
WeightedMethodsPerClassRule |
50 | Sum of cyclomatic complexities of all methods must not exceed N |
AfferentCouplingRule |
14 | Class must not be referenced by more than N other classes in the codebase |
InheritanceDepthRule |
3 | Class must not extend a chain of more than N ancestors |
LackOfCohesionRule |
1 | Class methods must not split into more than N disjoint LCOM4 groups |
Design
| Rule | Description |
|---|---|
FinalClassRule |
All concrete classes must be final |
MutableExceptionRule |
Exception classes must not have non-readonly properties |
ReturnCountRule |
Method must not have more than 1 return statement (default: 1) |
ProtectedMethodInFinalClassRule |
Final classes must not have protected methods |
ProhibitStaticMethodsRule |
Classes must not declare static methods, all visibility by default; opt-in allowNamedConstructors permits static fromX(): self { return new self(...); } |
ProhibitStaticPropertiesRule |
Classes must not declare static properties of any visibility |
ConstructorInitializationRule |
Constructor must only assign $this->property or call parent::__construct() |
BeImmutableRule |
All non-static properties must be readonly |
KeepInterfacesShortRule |
Interfaces must not declare too many methods (default: 10) |
NeverAcceptNullArgumentsRule |
Method and standalone function parameters must not be nullable |
NeverReturnNullRule |
Method and standalone function return types must not be nullable, return null is forbidden |
NoNullAssignmentRule |
Plain assignments of the null literal (variable, property, array element) are forbidden |
NoNullablePropertyRule |
Class property types must not be nullable (?Type, Type|null, null|Type, null) |
NoNullArgumentRule |
Passing the null literal to user-defined functions, methods (including static and nullsafe), or constructors is forbidden |
NeverUsePublicConstantsRule |
Class constants must not be public (explicitly or implicitly) |
Error-prone patterns
| Rule | Description |
|---|---|
NoParameterReassignmentRule |
Method parameters must not be reassigned |
IllegalCatchRule |
Catching Exception, Throwable, RuntimeException, Error is forbidden |
IllegalThrowsRule |
Declaring @throws Exception or other broad types in PHPDoc is forbidden |
InnerAssignmentRule |
Assignment inside conditions (if ($x = foo())) is forbidden |
ModifiedControlVariableRule |
Loop control variable must not be modified inside the loop body |
UnnecessaryLocalRule |
Local variable assigned and immediately returned/thrown must be inlined |
ConstantUsageRule |
Magic numbers and strings must be defined as named constants |
StringLiteralsConcatenationRule |
String literal concatenation via . or .= is forbidden |
TodoCommentRule |
TODO, FIXME, and XXX comments are forbidden in method bodies |
MissingThrowsRule |
Methods must declare @throws for every checked exception they throw (overridden methods inherit by default) |
HiddenFieldRule |
Method parameter or local variable must not shadow a class property (promoted constructors excluded, parameter takes precedence over local of the same name) |
RequireIgnoreReasonRule |
Every @phpstan-ignore and @psalm-suppress must carry a justification (default: 5 chars, parens for PHPStan, -- for Psalm) |
MultipleVariableDeclarationsRule |
Chained assignments ($a = $b = 1) and multiple statements on one line are forbidden (default: chained null chains rejected) |
NestedIfDepthRule |
Nested if depth must not exceed the configured limit (default: 1; elseif/else and Closure reset depth) |
NestedForDepthRule |
Nested loop depth (for/foreach/while/do-while) must not exceed the configured limit (default: 1; Closure and arrow functions reset depth) |
NestedTryDepthRule |
Nested try depth must not exceed the configured limit (default: 1; catch/finally, Closure, and arrow functions reset depth) |
SwitchDefaultRule |
Every switch must have a default case and it must be last |
SimplifyBooleanExpressionRule |
Comparisons with true/false literals are unnecessary and must be removed |
ExplicitInitializationRule |
Nullable typed properties (?T, T|null, null|T) must not be initialized to = null |
ThrowsCountRule |
Methods must not declare more @throws types than the configured maximum (default: 1) |
IfThenThrowElseRule |
else/elseif after an if block that ends with throw is forbidden |
NestedSwitchRule |
switch statements must not be nested inside another switch |
Naming
| Rule | Default | Description |
|---|---|---|
AbbreviationAsWordInNameRule |
4 | Identifier must not contain more than N consecutive capital letters |
VariableNameRule |
^[a-z][a-zA-Z]{2,19}$ |
Local variable name must match the configured pattern |
ParameterNameRule |
^(id|[a-z]{3,})$ |
Method parameter name must match the configured pattern |
CatchParameterNameRule |
^(e|ex|[a-z]{3,12})$ |
Catch parameter name must match the configured pattern |
ForbiddenClassSuffixRule |
12 suffixes | Class name must not end with a generic suffix (Manager, Helper, Util, ...) |
NoActorSuffixRule |
27 words, 6 ns prefixes | Class ending with -er/-or must match the allowedWords whitelist, or extend a class from a framework namespace |
PHPDoc style
| Rule | Description |
|---|---|
PhpDocPunctuationClassRule |
PHPDoc summary of every class must end with ., ?, or ! |
PhpDocPunctuationMethodRule |
PHPDoc summary of every method must end with ., ?, or ! |
AtclauseOrderRule |
PHPDoc tags must appear in order: @param → @return → @throws (configurable) |
PhpDocMissingClassRule |
Every named class must have a PHPDoc comment |
PhpDocMissingMethodRule |
Every public method in a class must have a PHPDoc comment (configurable) |
PhpDocMissingPropertyRule |
Every public property in a class must have a PHPDoc comment (configurable) |
PhpDocMissingParamRule |
Every parameter of a method with a PHPDoc block must have a matching @param tag |
PhpDocParamDescriptionRule |
Every @param tag must have a non-empty description after the parameter name |
PhpDocParamOrderRule |
@param tags must appear in the same order as the parameters of the method signature |
ReturnDescriptionCapitalRule |
@return tag description must start with a capital letter |
ParamDescriptionCapitalRule |
@param tag descriptions must start with a capital letter |
NoPhpDocForOverriddenRule |
Overridden methods (#[Override]) must not have a PHPDoc comment |
ClassConstantTypeHintRule |
Every class constant must have a native type declaration (PHP 8.3+) |
NoLineCommentBeforeDeclarationRule |
// and # comments are forbidden before class, method, and property declarations |
NoInlineCommentRule |
Comments inside method bodies are forbidden (suppress directives with @ are allowed) |
Configuration
All configurable rules expose their options as PHPStan parameters under the haspadar namespace. Override any limit in your phpstan.neon without touching service definitions:
parameters: haspadar: testsPaths: - '*/tests/*' methodLength: maxLines: 50 skipBlankLines: true skipComments: true fileLength: maxLines: 500 tooManyMethods: maxMethods: 10 onlyPublic: true prohibitStaticMethods: onlyPublic: true allowNamedConstructors: true parameterNumber: maxParameters: 5 ignoreOverridden: false cyclomaticComplexity: maxComplexity: 5 couplingBetweenObjects: maximum: 10 excludedClasses: - Symfony\Component\HttpFoundation\Request booleanExpressionComplexity: maxOperators: 2 classLength: maxLines: 250 skipBlankLines: true skipComments: true statementCount: maxStatements: 20 weightedMethods: maxWmc: 30 returnCount: max: 2 illegalCatch: illegalClassNames: - Exception - Throwable illegalThrows: illegalClassNames: - Error - Throwable ignoreOverriddenMethods: false phpDocPunctuationClass: checkCapitalization: false phpDocPunctuationMethod: checkCapitalization: false atclauseOrder: tagOrder: - '@param' - '@return' - '@throws' phpDocMissingMethod: checkPublicOnly: true skipOverridden: true phpDocMissingProperty: checkPublicOnly: true phpDocMissingParam: checkPublicOnly: true skipOverridden: true phpDocParamDescription: checkPublicOnly: true skipOverridden: true phpdocParamOrder: checkPublicOnly: true skipOverridden: true abbreviation: maxAllowedConsecutiveCapitals: 3 allowedAbbreviations: - JSON - HTTP variableName: pattern: '^[a-z][a-zA-Z]{2,9}$' allowedNames: - id - i - j - db parameterName: pattern: '^(id|[a-z]{3,})$' catchParamName: pattern: '^(e|ex|[a-z]{3,12})$' constantUsage: ignoreNumbers: - 0 - 1 checkStrings: false ignoreStrings: - '' stringConcat: allowMixed: false todoComment: keywords: - TODO - FIXME - XXX beImmutable: excludedClasses: - App\Entity\User - App\Entity\Order interfaceMethods: maxMethods: 5 forbiddenClassSuffix: forbiddenSuffixes: - Manager - Handler - Processor - Coordinator - Helper - Util - Utils - Utility - Data - Info - Information - Wrapper allowedSuffixes: - EventHandler - CommandHandler noActorSuffix: allowedWords: - User - Order - Number - Member - Owner - Customer - Folder - Header - Footer - Buffer - Layer - Marker - Parameter - Character - Identifier - Integer - Author - Visitor - Error - Color - Vendor - Vector - Factor - Actor - Director - Ancestor - Descriptor excludedParentNamespaces: - 'Symfony\' - 'Illuminate\' - 'Doctrine\' - 'Laminas\' - 'Yii\' - 'Laravel\' excludedClasses: - App\Legacy\UserManager missingThrows: skipOverridden: true hiddenField: ignoreConstructorParameter: true ignoreAbstractMethods: false ignoreSetter: false ignoreNames: [] requireIgnoreReason: minReasonLength: 5 allowedBareIdentifiers: [] multipleVarDecl: allowChainedNull: false nestedIfDepth: maxDepth: 1 nestedForDepth: maxDepth: 1 nestedTryDepth: maxDepth: 1 throwsCount: maxThrows: 1 afferentCoupling: maxAfferent: 10 ignoreInterfaces: true ignoreAbstract: true excludedClasses: - App\Kernel inheritanceDepth: maxDepth: 2 excludedClasses: - Symfony\Bundle\FrameworkBundle\Controller\AbstractController lackOfCohesion: maxLcom: 1 minMethods: 7 minProperties: 3 excludedClasses: - App\Entity\User
Default values match the defaults described in the rules table above. Omitting a parameter keeps the default. Diagnostic identifier for AtclauseOrderRule: haspadar.atclauseOrder (for targeted ignores, e.g. @phpstan-ignore haspadar.atclauseOrder).
testsPaths
haspadar.testsPaths is a list of fnmatch-style patterns (*, ? wildcards) that mark which files PHPStan should treat as tests. Production-oriented rules are not reported on matching files; test-oriented rules (when introduced) will only be reported on matching files. Patterns are matched against the absolute file path normalised to forward slashes, so wildcards must account for the project prefix (e.g. '*/tests/*', not 'tests/*'). The default empty list disables filtering and all rules report on all files.
AfferentCouplingRule also consumes testsPaths to keep the Ca graph production-only: classes declared in matching files are neither reported nor counted as sources of incoming references, so a production class consumed only by tests is not penalised when the test directory is part of the analysed paths.
NoActorSuffixRule — allowedWords vs renaming
When the rule reports a class like UserDispatcher, pick one of three fixes:
- Rename the class to a domain noun (preferred).
UserDispatcherbecomesUser,UserEvent,UserNotification— whatever the class actually is, not what it does. - Extend
allowedWordsif the suffix is a real English noun describing an entity, not an action. Good candidates:Container,Editor,Monitor,Sensor. Bad candidates (these are actors, not entities):Manager,Controller,Handler,Dispatcher,Coordinator,Orchestrator,Processor. - Add a framework namespace to
excludedParentNamespacesif the class is framework-managed (extends a controller base, implements an event-subscriber interface, etc.). Do not putControllerorHandlerintoallowedWordsfor this — it defeats the rule.
Rule of thumb: if the suffix describes what the class is, extend allowedWords. If it describes what the class does, rename.
allowedWords is matched case-sensitively against the last PascalCase segment of the class name. PHP class names follow PascalCase convention, so entries must be capitalized (User, not user).
RequireIgnoreReasonRule — where to put the reason
Two different delimiters, one per tool:
/** @phpstan-ignore foo.bar (reason in parentheses — PHPStan 1.11+ native) */ /** @psalm-suppress FooBar -- reason after double-dash (ESLint convention) */
minReasonLength counts trimmed characters, so padding does not help. allowedBareIdentifiers skips both the reason requirement and length check — use it for self-evident project-wide suppressions.
MissingThrowsRule — @throws inheritance for overridden methods
This rule replaces PHPStan's built-in exceptions.check.missingCheckedExceptionInThrows for class methods so that overrides and interface implementations do not have to repeat @throws from the parent contract.
Including rules.neon from this package automatically sets exceptions.check.missingCheckedExceptionInThrows: false — the built-in check is turned off and replaced by haspadar.missingThrows. Do not re-enable the built-in flag in your own phpstan.neon: both rules will then fire on the same code and you will receive duplicate errors.
Current scope: only class methods are covered. Standalone functions and PHP 8.4 property hooks are not yet checked by haspadar.missingThrows; if your codebase needs @throws enforcement there, keep those analyses through separate means until the corresponding rules are shipped.
skipOverridden: true(default) — overridden/interface-implementing methods inherit@throwsfrom the parent and are not required to declare it themselves.skipOverridden: false— every method must declare@throwsfor every checked exception it throws, including overrides.
Suppressing violations
Suppress a single occurrence inline using @phpstan-ignore with the rule's identifier and a mandatory reason (enforced by RequireIgnoreReasonRule):
/** @phpstan-ignore haspadar.methodLength (legacy method, extraction tracked in #123) */ public function process(): void { ... }
Suppress globally for specific paths in your phpstan.neon — useful for generated code or third-party adapters:
parameters: ignoreErrors: - identifier: haspadar.finalClass paths: - src/Generated/ - identifier: haspadar.methodLength
Every identifier follows the pattern haspadar.<camelCaseRuleName> — for example haspadar.tooManyMethods, haspadar.nestedIfDepth, haspadar.throwsCount.
Experimental rules
Some rules are not registered by default because their usefulness depends strongly on project topology. They live behind an opt-in include so adopting projects do not fail on legitimate code (for example, entry-point classes that naturally have instability I = 1).
To enable them, add rules-experimental.neon to your phpstan.neon:
includes: - vendor/haspadar/phpstan-rules/rules.neon - vendor/haspadar/phpstan-rules/rules-experimental.neon
| Rule | Why opt-in |
|---|---|
InstabilityRule |
Absolute threshold on a relative metric; I = 1 is normal for entry-point classes |
BooleanArgumentFlagRule |
Public method must not accept a bool parameter (Clean Code "flag argument" smell); produces false positives on typed value objects that legitimately wrap a bool |
Once enabled, configure the rule like any other:
parameters: haspadar: instability: maxInstability: 0.8 minDependencies: 5 ignoreInterfaces: true ignoreAbstract: true excludedClasses: - App\Controller\HomeController
Contributing
Fork the repository, apply changes, and open a pull request.
License
MIT