PHPStan finds bugs in PHP code without running it. It analyses the entire codebase and reports type errors, undefined methods/properties/variables, dead code, incorrect PHPDoc types, and many other issues. It understands PHP's type system deeply, including generics, union/intersection types, literal types, conditional return types, and template types expressed through PHPDocs.
- Rule levels 0-10: Incremental adoption from basic checks (level 0) to strict mixed type enforcement (level 10). Levels are cumulative. Level 0 covers unknown classes/functions/methods and undefined variables. Level 5 checks argument types. Level 6 requires typehints. Level 9 enforces explicit
mixed. Level 10 enforces implicitmixed. - Baseline: Allows adopting higher rule levels by recording existing errors in a baseline file, so only new errors are reported.
- Bleeding edge: Preview of next major version features, shipped in current stable release via
bleedingEdge.neon. - Result cache: PHPStan caches analysis results and only re-analyses changed files and their dependents.
- Parallel analysis: Files are analysed in parallel across multiple child processes using React PHP.
- Configuration: NEON format (Nette configuration). Main config is
phpstan.neon, level configs inconf/config.level*.neon, services inconf/services.neon.
# Analyse with a specific level
vendor/bin/phpstan analyse -l 8 src tests
# Clear result cache
vendor/bin/phpstan clear-result-cache
# Generate baseline
vendor/bin/phpstan analyse --generate-baseline
# Debug mode (shows which files are being analysed)
vendor/bin/phpstan analyse --debugmake testsRules are tested using PHPStan\Testing\RuleTestCase, type extensions with PHPStan\Testing\TypeInferenceTestCase.
The codebase lives under src/ with PSR-4 autoloading mapping PHPStan\ to src/. Key architectural components:
The analysis pipeline: Analyser orchestrates FileAnalyser, which uses NodeScopeResolver to walk the AST and invoke registered Rule implementations.
NodeScopeResolver (src/Analyser/NodeScopeResolver.php) - The central engine of PHPStan. It traverses the AST (from nikic/php-parser) and maintains a MutatingScope that gets updated at each node. It handles:
- Control flow analysis (if/else, switch, try/catch, loops, match)
- Variable assignments and type narrowing
- Function/method call resolution
- Closure and arrow function scoping
- Type narrowing from conditions (
instanceof,is_*(),===, etc.) - PHPDoc type resolution and
@var/@param/@returnprocessing - Invoking registered Rules and Collectors at each AST node
MutatingScope (src/Analyser/MutatingScope.php) - Holds the current state of analysis after each AST node. It tracks:
- Variable types (assigned, possibly-defined, narrowed)
- Current context: namespace, class, method/function, trait, anonymous function
- Property types and initialization state
- Constant values
- Expression types via
getType(Expr $expr): Type - Native types vs PHPDoc types
The Scope interface (src/Analyser/Scope.php) is the public API that rules and extensions use. MutatingScope is the internal implementation.
TypeSpecifier (src/Analyser/TypeSpecifier.php) - Narrows types based on conditions. When NodeScopeResolver processes an if ($x instanceof Foo), TypeSpecifier determines that $x is Foo in the truthy branch and not Foo in the falsy branch. It uses TypeSpecifierContext (truthy/falsey/true/false/null) to track which branch is being processed. Extensible via FunctionTypeSpecifyingExtension, MethodTypeSpecifyingExtension, and StaticMethodTypeSpecifyingExtension.
ExpressionTypeHolder (src/Analyser/ExpressionTypeHolder.php) - Stores an expression's type together with a TrinaryLogic certainty (Yes = definitely this type, Maybe = possibly this type). This is the building block of how MutatingScope tracks variable types - it maps expression keys to ExpressionTypeHolder instances.
ConstantResolver (src/Analyser/ConstantResolver.php) - Resolves PHP constant names to their types. Handles 150+ predefined PHP constants with PHP-version-aware types (e.g. different return types based on PHP_VERSION_ID). Also resolves user-configured constant type mappings.
InitializerExprTypeResolver (src/Reflection/InitializerExprTypeResolver.php) - Resolves types of constant expressions and initializers (default parameter values, property defaults, constant values). Handles arithmetic, casts, function calls, and binary operations in constant contexts where a full Scope is not available.
AnalyseCommandparses CLI arguments, builds the DI containerAnalyserreceives the list of files, sets upNodeScopeResolverwith the full file list- For parallel runs,
ParallelAnalyserdistributes files across worker processes via TCP using NDJSON protocol (React event loop). Default: up to 32 processes, 20 files per job, 600s timeout. - Each worker runs
FileAnalyser::analyseFile()which parses a file to AST, then callsNodeScopeResolver::processStmtNodes() NodeScopeResolverwalks the AST depth-first. At each node, it updatesMutatingScopeand invokes all registeredRuleandCollectorcallbacks for that node type- Results (errors, dependencies, collected data) flow back to the main process
- After all files are processed,
CollectedDataNoderules run with the aggregated collector data ResultCacheManagersaves results keyed by file SHA256 hashes and a dependency graph for incremental re-analysis
ResultCacheManager enables incremental analysis. It tracks:
- SHA256 hashes of all analysed files
- Dependency graph between files (so changing one file re-analyses its dependents)
- Exported nodes (class/function signatures) to detect API changes
- PHP version, loaded extensions, and config hash for cache invalidation
On subsequent runs, only changed files and their transitive dependents are re-analysed.
IgnoredErrorHelper processes error ignore patterns from configuration and inline @phpstan-ignore comments. Supports regex patterns, error identifiers, and file-specific ignoring. Tracks which ignore patterns were matched so unmatched patterns can be reported.
Implementations of the Type interface (src/Type/Type.php) represent everything PHPStan knows about types. Each type knows:
- What it accepts (
accepts()) and what is a supertype of it (isSuperTypeOf()) - What properties/methods/constants it has
- What operations result in what types (array operations, arithmetic, string operations, etc.)
- How to describe itself for error messages (
describe()) - How to narrow itself (
tryRemove(), generalize, traverse)
Key type classes:
ObjectType,StringType,IntegerType,FloatType,BooleanType,NullType,ArrayType,MixedType,NeverType,VoidTypeUnionType,IntersectionType- composite typesConstant\ConstantStringType,Constant\ConstantIntegerType,Constant\ConstantArrayType- literal/known valuesGeneric\GenericObjectType,Generic\TemplateType- genericsAccessory\AccessoryNonEmptyStringType,Accessory\NonEmptyArrayType, etc. - combined via intersection for refined types likenon-empty-stringIntegerRangeType- integer ranges likeint<0, 100>Enum\EnumCaseObjectType- enum casesClosureType,CallableType- callable typesStaticType,ThisType- late static binding
TypeCombinator - Used instead of constructing UnionType/IntersectionType directly. Handles type normalization (e.g. mixed|int becomes mixed, string&int becomes never).
TrinaryLogic - Three-valued logic (yes/no/maybe) used throughout the type system. Many Type methods return TrinaryLogic instead of bool because type relationships aren't always certain (e.g. mixed might be a string - that's maybe).
To query whether a type is a specific type, use isSuperTypeOf(), not instanceof. For example, (new StringType())->isSuperTypeOf($type)->yes() correctly handles union types, intersection types with accessory types, etc. There are also shortcut methods like $type->isString(), $type->isInteger(), etc.
Rules implement the Rule<TNodeType> interface (src/Rules/Rule.php):
getNodeType(): string- returns the AST node class to listen forprocessNode(Node $node, Scope $scope): array- returns errors found at this node
Rules are organized into subdirectories by category: Classes/, Methods/, Properties/, Functions/, Variables/, DeadCode/, Generics/, PhpDoc/, Cast/, Comparison/, Exceptions/, Pure/, Arrays/, Types/, etc.
Rules are registered in configuration via the phpstan.rules.rule service tag or the rules: section. Different rule levels activate different rules via conf/config.level*.neon.
Collectors (src/Collectors/) - For rules that need cross-file information (e.g. unused code detection). Collectors gather data across all files in parallel processes, then a rule registered for CollectedDataNode processes the aggregated data.
PHPStan has its own reflection layer, primarily backed by the BetterReflection library (ondrejmirtes/better-reflection), which provides static reflection (reading code without loading it).
ReflectionProvider (src/Reflection/ReflectionProvider.php) - Central entry point for looking up classes, functions, and constants.
ClassReflection (src/Reflection/ClassReflection.php) - Represents classes, interfaces, traits, and enums. Provides access to methods, properties, constants, parent classes, interfaces, traits, generics, PHPDocs, and attributes.
Class reflection extensions allow describing magic properties/methods from __get/__set/__call.
The reflection layer also includes ParametersAcceptor for function/method signatures (with multi-variant support for overloaded built-in functions), SignatureMap for built-in PHP function signatures, and stub files for overriding third-party type information.
PHPStan augments the nikic/php-parser AST with custom virtual nodes (src/Node/):
FileNode- wraps an entire fileInClassNode,InClassMethodNode,InFunctionNode,InTraitNode- provide scope-aware context (e.g.InClassNodelets rules access$scope->getClassReflection())ClassPropertiesNode,ClassMethodsNode,ClassConstantsNode- aggregate all properties/methods/constants of a class (useful for checking completeness)ClassPropertyNode- unifies traditional and promoted propertiesCollectedDataNode- carries aggregated data from collectorsExecutionEndNode- marks unreachable code pointsMatchExpressionNode,BooleanAndNode,BooleanOrNode- enhanced representations of expressions
Uses phpstan/phpdoc-parser to parse PHPDoc comments into an AST, then resolves PHPDoc types into PHPStan\Type\Type objects via TypeNodeResolver. Handles @param, @return, @var, @throws, @template, @extends, @implements, @phpstan-assert, @phpstan-type, @phpstan-import-type, conditional return types, and more.
Uses Nette DI container. Services are configured in NEON files. Extensions register via service tags like phpstan.rules.rule, phpstan.broker.dynamicMethodReturnTypeExtension, phpstan.typeSpecifier.functionTypeSpecifyingExtension, phpstan.collector, etc.
The #[AutowiredService] and #[AutowiredParameter] attributes are used for automatic service registration.
Collects structural information about a class during AST traversal: properties, methods, method calls, property reads/writes. Used by virtual nodes like ClassPropertiesNode and ClassMethodsNode to provide aggregate information for rules that check class-level invariants (e.g. unused private properties, uninitialized properties).
Patcher coordinates automatic error fixes. Rules can attach fix information to errors via RuleErrorBuilder. The PhpPrinter handles code generation, ReplacingNodeVisitor performs AST node replacements, and indentation is preserved via PhpPrinterIndentationDetectorVisitor.
PHPStan\Parser- Wraps nikic/php-parser with caching, visitor registration, and PHPStan-specific AST enrichment (e.g.ArrayMapArgVisitor,ImmediatelyInvokedClosureVisitor)PHPStan\Parallel-ParallelAnalyser+Scheduler+ProcessPooldistribute file analysis across child processes via React TCP serverPHPStan\Command- CLI commands (AnalyseCommand,ClearResultCacheCommand, etc.) and error formatters (table, json, github actions, etc.)PHPStan\Dependency- Tracks file dependencies for incremental analysis / result cache.ExportedNoderepresents a class/function/constant signature for detecting API changes.PHPStan\File- File path resolution and readingPHPStan\Php-PhpVersionabstraction with version source tracking (runtime, config, composer platform). Methods likesupportsEnums(),supportsReadonlyProperties(), etc. for version-specific behavior.PHPStan\Cache- Caching infrastructurePHPStan\Testing-RuleTestCase,TypeInferenceTestCase, and other test utilities
PHPStan is highly extensible. Key extension interfaces:
- Custom rules -
PHPStan\Rules\Ruleinterface, tag:phpstan.rules.rule - Dynamic return type extensions -
DynamicMethodReturnTypeExtension,DynamicStaticMethodReturnTypeExtension,DynamicFunctionReturnTypeExtension - Type-specifying extensions -
MethodTypeSpecifyingExtension,StaticMethodTypeSpecifyingExtension,FunctionTypeSpecifyingExtension- for custom type narrowing (likeis_int()) - Class reflection extensions -
PropertiesClassReflectionExtension,MethodsClassReflectionExtension- for magic properties/methods - Dynamic throw type extensions - describe when functions throw based on arguments
- Closure type extensions - override closure parameter/return types or
$thisbinding - Custom PHPDoc types -
TypeNodeResolverExtensionfor custom type syntax - Collectors -
PHPStan\Collectors\Collectorfor cross-file analysis - Error formatters - custom output formats
- Restricted usage extensions - simple interfaces to restrict where methods/properties/functions can be called from
- Allowed subtypes - define sealed class hierarchies
- Always-read/written properties, always-used constants/methods - suppress false positives for dead code detection
Code marked with @api must not break backward compatibility for existing usages in third-party extensions and packages. The @api tag signals that the code is part of the public API for extension developers and is protected from breaking changes across minor versions. Key rules:
@apiclasses: All public methods can be called by extensions. Non-final classes can be extended.@apiinterfaces: All methods can be called. Interfaces can be implemented unless also marked with@api-do-not-implementor similar restrictions.- Constructors: Changing a constructor that is NOT marked with
@apiin an@api-marked class is okay — extensions should use dependency injection, not direct instantiation. @api+@api-do-not-implementinterfaces: Adding new methods is okay, since third parties are not expected to implement these interfaces.- Non-
@apicode: Any code without@apimay change in minor versions without notice.
When making changes, check whether the affected code has @api tags. If it does, ensure existing call sites in third-party code would not break.
Based on analysis of recent releases (2.1.30-2.1.38), these are the recurring patterns for how bugs are found and fixed:
A recurring cleanup theme: never use $type instanceof StringType or similar. This misses union types, intersection types with accessory types, and other composite forms. Always use $type->isString()->yes() or (new StringType())->isSuperTypeOf($type). Multiple PRs have systematically replaced instanceof *Type checks throughout the codebase.
When a bug requires checking a type property across the codebase, the fix is often to add a new method to the Type interface rather than scattering instanceof checks or utility function calls throughout rules and extensions. This ensures every type implementation handles the query correctly (including union/intersection types which delegate to their inner types) and keeps the logic centralized.
Historical analysis of Type.php via git blame shows that new methods are added for several recurring reasons:
-
Replacing scattered
instanceofchecks (~30%): Methods likeisNull(),isTrue(),isFalse(),isString(),isInteger(),isFloat(),isBoolean(),isArray(),isScalar(),isObject(),isEnum(),getClassStringObjectType(),getObjectClassNames(),getObjectClassReflections()were added to replace$type instanceof ConstantBooleanType,$type instanceof StringType, etc. Each type implements the method correctly — e.g.,UnionType::isNull()returnsyesonly if all members are null,maybeif some are,noif none are. This is impossible to get right with a singleinstanceofcheck. -
Moving logic from TypeUtils/extensions into Type (~35%): Methods like
toArrayKey(),toBoolean(),toNumber(),toFloat(),toInteger(),toString(),toArray(),flipArray(),getKeysArray(),getValuesArray(),popArray(),shiftArray(),shuffleArray(),reverseSortArray(),getEnumCases(),isCallable(),getCallableParametersAcceptors(),isList()moved scattered utility logic into polymorphic dispatch. When logic lives in a utility function it typically uses a chain ofif ($type instanceof X) ... elseif ($type instanceof Y) ...which breaks when new type classes are added or misses edge cases in composite types. -
Supporting new type features (~15%): Methods like
isNonEmptyString(),isNonFalsyString(),isLiteralString(),isClassString(),isNonEmptyArray(),isIterableAtLeastOnce()were added as PHPStan gained support for more refined types (accessory types in intersections). These enable rules to query refined properties without knowing how the refinement is represented internally. -
Bug fixes through better polymorphism (~10%): Some bugs are directly fixed by adding a new Type method. For example,
isOffsetAccessLegal()fixed false positives about illegal offset access by letting each type declare whether$x[...]is valid.setExistingOffsetValueType()(distinct fromsetOffsetValueType()) fixed array list type preservation bugs.toCoercedArgumentType()fixed parameter type contravariance issues during type coercion. -
Richer return types (~5%): Methods that returned
TrinaryLogicwere changed to returnAcceptsResultorIsSuperTypeOfResult, which carry human-readable reasons for why a type relationship holds or doesn't. This enabled better error messages without changing the call sites significantly.
When considering a bug fix that involves checking "is this type a Foo?", first check whether an appropriate method already exists on Type. If not, consider whether adding one would be the right fix — especially if the check is needed in more than one place or involves logic that varies by type class.
MutatingScope has separate methods for entering arrow functions (enterArrowFunctionWithoutReflection) and closures (enterAnonymousFunctionWithoutReflection). Both iterate over parameters and assign types, but they must use the same logic for computing parameter types. In particular, both must call getFunctionType($parameter->type, $isNullable, $parameter->variadic) to properly handle variadic parameters (wrapping the inner type in ArrayType). Shortcuts like new MixedType() for untyped parameters skip the variadic wrapping and cause variadic args to be typed as mixed instead of array<int|string, mixed>. When fixing bugs in one method, check the other for the same issue.
When two scopes are merged (e.g. after if/else branches), MutatingScope::generalizeWith() must invalidate dependent expressions. If variable $i changes, then $locations[$i] must be invalidated too. Bugs arise when stale ExpressionTypeHolder entries survive scope merges. Fix pattern: in MutatingScope, when a root expression changes, skip/invalidate all deep expressions that depend on it.
When a method with side effects is called, invalidateExpression() invalidates tracked expression types that depend on the call target. When $this is invalidated, shouldInvalidateExpression() also matches self, static, parent, and class name references — so self::$prop, $this->prop, etc. all get invalidated. However, private properties of the current class cannot be modified by methods declared in a different class (parent/other). The invalidatingClass parameter on invalidateExpression() and shouldInvalidateExpression() enables skipping invalidation for private properties whose declaring class differs from the method's declaring class. This is checked via isPrivatePropertyOfDifferentClass(). The pattern mirrors the existing readonly property protection (isReadonlyPropertyFetch). Both NodeScopeResolver call sites (instance method calls at ~line 3188, static method calls at ~line 3398) pass $methodReflection->getDeclaringClass() as the invalidating class.
NodeScopeResolver::processArgs() has special handling for Closure::bind() and Closure::bindTo() calls. When the first argument is a closure/arrow function literal, a $closureBindScope is created with $this rebound to the second argument's type, and this scope is used to process the closure body. However, this $closureBindScope must ONLY be applied when the first argument is actually an Expr\Closure or Expr\ArrowFunction. If the first argument is a general expression that returns a closure (e.g. $this->hydrate()), the expression itself must be evaluated in the original scope — otherwise $this in the expression gets incorrectly resolved as the bound object type instead of the current class. The condition at the $scopeToPass assignment must check the argument node type.
When assigning to an array offset, NodeScopeResolver must distinguish:
SetExistingOffsetValueTypeExpr- modifying a key known to exist (preserves list type, doesn't widen the array)SetOffsetValueTypeExpr- adding a potentially new key (may break list type, widens the array)
Misusing these leads to false positives like "might not be a list" or incorrect offset-exists checks. The fix is in NodeScopeResolver where property/variable assignments are processed.
This distinction also applies in MutatingScope::enterForeach(). When a foreach loop iterates by reference (foreach ($list as &$value)), modifying $value changes an existing offset, not a new one. The IntertwinedVariableByReferenceWithExpr created for the no-key by-reference case must use SetExistingOffsetValueTypeExpr (not SetOffsetValueTypeExpr) so that AccessoryArrayListType::setExistingOffsetValueType() preserves the list type. Using SetOffsetValueTypeExpr causes AccessoryArrayListType::setOffsetValueType() to return ErrorType for non-null/non-zero offsets, destroying the list type in the intersection.
Many bugs involve ConstantArrayType (array shapes with known keys). Common issues:
hasOffsetValueType()returning wrong results for expression-based offsets- Offset types not being unioned with empty array when the offset might not exist
array_key_exists()not properly narrowing tonon-empty-arrayOversizedArrayType(array shapes that grew too large to track precisely) needing correctisSuperTypeOf()and truthiness behavior
Fixes typically involve ConstantArrayType, TypeSpecifier (for narrowing after array_key_exists/isset), and MutatingScope (for tracking assignments).
InitializerExprTypeResolver::getArrayType() computes the type of array literals like [...$a, ...$b]. It uses ConstantArrayTypeBuilder to build the result type. When a spread item is a single constant array (getConstantArrays() returns exactly one), its key/value pairs are added individually. When it's not (e.g., array<string, mixed>), the builder is degraded via degradeToGeneralArray(), and all subsequent items are merged into a general ArrayType with unioned keys and values.
The degradation loses specific key information. To preserve it, getArrayType() tracks HasOffsetValueType accessories for non-optional keys from constant array spreads with string keys. After building, these are intersected with the degraded result. When a non-constant spread appears later that could overwrite tracked keys (its key type is a supertype of the tracked offsets), those entries are invalidated. This ensures correct handling of PHP's spread ordering semantics where later spreads override earlier ones for same-named string keys.
NodeScopeResolver handles NullsafeMethodCall and NullsafePropertyFetch by temporarily removing null from the variable's type (ensureShallowNonNullability), processing the inner expression, then restoring the original nullable type (revertNonNullability). When the expression is an ArrayDimFetch (e.g. $arr['key']?->method()), specifyExpressionType recursively narrows the parent array type via TypeCombinator::intersect with HasOffsetValueType. This intersection only narrows and cannot widen, so revertNonNullability fails to restore the parent array's offset type. The fix is to also save and restore the parent expression's type in ensureShallowNonNullability. Without this, subsequent uses of the same nullsafe call are falsely reported as "Using nullsafe method call on non-nullable type" because the parent array retains the narrowed (non-null) offset type.
Loops are a frequent source of false positives because PHPStan must reason about types across iterations:
- List type preservation in for loops: When appending to a list inside a
forloop, the list type must be preserved if operations maintain sequential integer keys. - Always-overwritten arrays in foreach: NodeScopeResolver examines
$a[$k]at loop body end andcontinuestatements. If nobreakexists, the entire array type can be rewritten based on the observed value types. - Variable types across iterations: PHP Fibers are used (PHP 8.1+) for more precise analysis of repeated variable assignments in loops, by running the loop body analysis multiple times to reach a fixpoint.
Match expressions in NodeScopeResolver (around line 4154) process each arm and merge the resulting scopes. The critical pattern for variable certainty is: when a match is exhaustive (has a default arm or an always-true condition), arm body scopes should be merged only with each other (not with the original pre-match scope). This mirrors how if/else merging works — $finalScope starts as null, and each branch's scope is merged via $branchScope->mergeWith($finalScope). When the match is NOT exhaustive, the original scope must also participate in the merge (via $scope->mergeWith($armBodyFinalScope)) because execution may skip all arms and throw UnhandledMatchError. The mergeVariableHolders() method in MutatingScope uses ExpressionTypeHolder::createMaybe() for variables present in only one scope, so merging an arm scope that defines $x with the original scope that lacks $x degrades certainty to "maybe" — this is the root cause of false "might not be defined" reports for exhaustive match expressions.
GenericClassStringType represents class-string<T> where T is the generic object type. When the generic type is a union (e.g., class-string<Car|Bike|Boat>), it's a single GenericClassStringType with an inner UnionType. This is distinct from class-string<Car>|class-string<Bike>|class-string<Boat> which is a UnionType of individual GenericClassStringTypes.
The tryRemove() method handles removing a ConstantStringType (e.g., 'Car') from the class-string type. It must check whether the class is final — only for final classes can exact class-string removal be performed, since non-final classes could have subclasses whose class-strings would still be valid values. When the inner generic type is a union, TypeCombinator::remove() is used to remove the corresponding ObjectType from the inner union.
This affects match expression exhaustiveness: class-string<FinalA|FinalB> matched against FinalA::class and FinalB::class is exhaustive only because both classes are final.
StaticType::transformStaticType() is used when resolving method return types on a StaticType caller. It traverses the return type and transforms StaticType/ThisType instances via changeBaseClass(). Since ThisType extends StaticType, both are caught by the $type instanceof StaticType check. The critical invariant: when the caller is a StaticType (not ThisType) and the method's return type contains ThisType, the ThisType must be downgraded to a plain StaticType. This is because $this (the exact instance) cannot be guaranteed when calling on a static type (which could be any subclass instance). ThisType::changeBaseClass() returns a new ThisType, which preserves the $this semantics — so the downgrade must happen explicitly after changeBaseClass(). The CallbackUnresolvedMethodPrototypeReflection at line 91 also has special handling for ThisType return types intersected with selfOutType.
PHPDoc types (@return, @param, @throws, @property) are inherited through class hierarchies. Bugs arise when:
- Child methods with narrower native return types don't inherit parent PHPDoc return types
@propertytags on parent classes don't consider native property types on children- Trait PHPDoc resolution uses wrong file context
The PhpDocInheritanceResolver and PhpDocBlock classes handle this. Recent optimization: resolve through reflection instead of re-walking the hierarchy manually.
Many built-in PHP functions need DynamicFunctionReturnTypeExtension implementations because their return types depend on arguments:
array_rand(),array_count_values(),array_first()/array_last(),filter_var(),curl_setopt(), DOM methods, etc.- Extensions live in
src/Type/Php/and are registered inconf/services.neon - Each reads the argument types from
Scope::getType()and returns a more preciseType
PHPStan maintains its own signature map for built-in PHP functions in functionMap.php and delta files. Fixes involve:
- Correcting return types (e.g.
DOMNode::C14Nreturningstring|false) - Adding
@param-outfor reference parameters (e.g.stream_socket_client) - Marking functions as impure (e.g.
time(), Redis methods) - PHP-version-specific signatures (e.g.
bcroundonly in PHP 8.4+)
PHP-parser's NameResolver resolves names through use statements. When preserveOriginalNames: true is configured (as PHPStan does in conf/services.neon), the original unresolved Name node is preserved as an originalName attribute on the resolved FullyQualified node. This matters for case-sensitivity checking: when use DateTimeImmutable; is followed by dateTimeImmutable in a typehint, the resolved node has the case from the use statement (DateTimeImmutable), losing the wrong case from the source. The originalName attribute preserves the source-code case (dateTimeImmutable). Rules that check class name case (like class.nameCase via ClassCaseSensitivityCheck) must use this attribute rather than relying on Type::getReferencedClasses() which returns already-resolved names. The fix pattern is in FunctionDefinitionCheck::getOriginalClassNamePairsFromTypeNode() which extracts original-case class names from AST type nodes.
PHPStan tracks whether expressions/statements have side effects ("impure points"). This enables:
- Reporting useless calls to pure methods (
expr.resultUnused) - Detecting void methods with no side effects
@phpstan-pureenforcement
Bugs occur when impure points are missed (e.g. inherited constructors of anonymous classes) or when clearstatcache() calls don't invalidate filesystem function return types.
FunctionCallParametersCheck (src/Rules/FunctionCallParametersCheck.php) validates arguments passed to functions/methods. For by-reference parameters, it checks whether the argument is a valid lvalue (variable, array dim fetch, property fetch). It also allows function/method calls that return by reference (&getString(), &staticGetString(), &refFunction()), using returnsByReference() on the resolved reflection. The class is manually instantiated in ~20 test files, so adding a constructor parameter requires updating all of them. The Scope interface provides getMethodReflection() for method calls, while ReflectionProvider (injected into the class) is needed for resolving function reflections.
When spreading a constant array into a function/method call (foo(...$array)), FunctionCallParametersCheck::check() (lines 139-213) expands each array position into an individual argument. For each position, it checks whether the key is optional (getOptionalKeys()), extracts the value type, and determines the key name. Optional keys (array positions that might not exist) are normally skipped to avoid asserting they're always present.
However, when the optional key has a string name (named argument), skipping it causes a fallback path (lines 195-203) that loses the key-to-type correspondence. The fallback uses getIterableValueType() which unions ALL value types, then passes this as a single generic unpacked argument. This causes false positives when different keys have different value types — e.g., non-empty-array{width?: int, bgColor?: string} spread into Foo(int|null $width, string|null $bgColor) reports "int|string given" for $width because the fallback unions int and string. The fix: only skip optional keys when they don't have a named key ($keyArgumentName === null), so named optional keys are still expanded as individual named arguments with their correct per-key types.
- Rule tests: Extend
RuleTestCase, implementgetRule(), call$this->analyse([__DIR__ . '/data/my-test.php'], [...expected errors...]). Expected errors are[message, line]pairs. Test data files live intests/PHPStan/Rules/*/data/. - Type inference tests: Use
assertType()andassertNativeType()helper functions in test data files. The test runner verifies PHPStan infers the declared types at eachassertType()call. - Regression tests: For each bug fix, add a test data file reproducing the issue (e.g.
tests/PHPStan/Rules/*/data/bug-12345.phportests/PHPStan/Analyser/nsrt/bug-12345.php). - Integration tests:
AnalyserIntegrationTestruns full analysis on test files and checks error output.
Recent work on PHP 8.5 support shows the pattern:
- Parser support: Update nikic/php-parser dependency, handle new AST node types
- NodeScopeResolver: Handle new syntax (pipe operator, clone-with, void cast)
- Type system: New type representations if needed
- Rules: Version-gated rules (e.g. deprecated casts only reported on PHP 8.5+,
#[NoDiscard]only on PHP 8.5+) - InitializerExprTypeResolver: Support new constant expression forms (casts, first-class callables, static closures in initializers)
- Reflection: Support new attributes, property features (asymmetric visibility on static properties,
#[Override]on properties) - PhpVersion: Add detection methods like
supportsPropertyHooks(),supportsPipeOperator(), etc. - Stubs: Update function/class stubs for new built-in functions and changed signatures
When adding or editing PHPDoc comments in this codebase, follow these guidelines:
- Class-level docs on interfaces and key abstractions: Explain the role of the interface, what implements it, and how it fits into the architecture. Mention non-obvious patterns like double-dispatch (CompoundType), the intersection-with-base-type requirement (AccessoryType), or the instanceof-avoidance rule (TypeWithClassName).
- Non-obvious behavior: Document when a method's behavior differs from what its name suggests, or when there are subtle contracts. For example:
getDeclaringClass()returning the declaring class even for inherited members,setExistingOffsetValueType()vssetOffsetValueType()preserving list types differently, orgetWritableType()potentially differing fromgetReadableType()due to asymmetric visibility. @apitags: Keep these — they mark the public API for extension developers.@phpstan-asserttags: Keep these — they provide type narrowing information that PHPStan uses.@return,@param,@templatetags: Keep when they provide type information not expressible in native PHP types (e.g.@return self::SOURCE_*,@param array<string, Type>).
- Obvious from the method name: Do not write "Returns the name" above
getName(), "Returns the value type" abovegetValueType(), or "Returns whether deprecated" aboveisDeprecated(). If the method name says it all, add no description. - Obvious to experienced PHP developers: Do not explain standard visibility rules ("public methods are always callable, protected methods are callable from subclasses..."), standard PHP semantics, or basic design patterns.
- Obvious from tags: Do not add prose that restates what
@return,@phpstan-assert, or@paramtags already say. If@return non-empty-string|nullis present, do not also write "Returns a non-empty string or null". - Factory method descriptions that repeat the class-level doc: If the class doc already explains the levels/variants (like VerbosityLevel or GeneralizePrecision), don't repeat those descriptions on each factory method. A bare
@apitag is sufficient. - Getter/setter/query methods on value objects: Methods like
isInvariant(),isCovariant(),isEmpty(),count(),getType(),hasType()on simple value objects need no PHPDoc.
- Keep descriptions concise — one or two sentences for method docs when needed.
- Use imperative voice without "Returns the..." preambles when a brief note suffices. Prefer
/** Replaces unresolved TemplateTypes with their bounds. */over a multi-line block. - Preserve
@apiand type tags on their own lines, with no redundant description alongside them.
nikic/php-parser^5.7.0 - PHP AST parsingondrejmirtes/better-reflection- Static reflection (reading code without loading it)phpstan/phpdoc-parser- PHPDoc parsingnette/di- Dependency injection containernette/neon- Configuration file formatreact/child-process,react/async- Parallel analysissymfony/console- CLI interfacehoa/compiler- Used for regex type parsing
TypeSpecifier::specifyTypesInCondition() handles ternary expressions ($cond ? $a : $b) for type narrowing. In a truthy context (e.g., inside assert()), the ternary is semantically equivalent to ($cond && $a) || (!$cond && $b) — meaning if the condition is true, the "if" branch must be truthy, and if false, the "else" branch must be truthy. The fix converts ternary expressions to this BooleanOr(BooleanAnd(...), BooleanAnd(...)) form so the existing OR/AND narrowing logic handles both branches correctly. This enables assert($cond ? $x instanceof A : $x instanceof B) to narrow $x to A|B. The AssertFunctionTypeSpecifyingExtension calls specifyTypesInCondition with TypeSpecifierContext::createTruthy() context for the assert argument.