Repository navigation
Error tip for @phpstan-impure #14183
Description
Feature request
I have an idea. Users need help understanding that errors like these:
<?php
declare(strict_types=1);
/**
* Calculate something and return a value that could be different each time
*
* @return int<0, 1000> result different at each call
*/
function calculateSomething(): int
{
return rand(0, 1000);
}
// calculated result can be between 0 and 1000
if (calculateSomething() > 0) {
// the new calculated result can also be between 0 and 1000
// PHPStan error: Comparison operation ">" between int<1, 1000> and 0 is always true.
if (calculateSomething() > 0) {
// can be something else
}
}and:
final class ValidateIndicatorValue {
public function validate(Indicator $indicator, int $id, \DateTimeInterface $date): void {
$value = $indicator->valueWithId($id);
if ($date->format('Y-m-d') !== $value->date()->format('Y-m-d')) {
if ($value->isLocked()) {
throw new \Exception('Cannot modify locked');
}
$value->changeIndicatorValueDate($id, $date);
// PHPStan error: If condition is always false.
if ($value->isLocked()) {
throw new \Exception('Cannot modify to a locked year');
}
}
}
}Can be solved by adding PHPDoc tag @phpstan-impure above changeIndicatorValueDate method and
calculateSomething function.
I'd like to add a tip to rules that report similar errors:
StrictComparisonOfDifferentTypesRule
IfConstantConditionRule
ImpossibleInstanceOfRule
and more from Comparison namespace
But this tip "Solve this error by adding PHPDoc tag @phpstan-impur above X()."
should be added only in this specific scenario where it'd really help.
Which means we need to track it in Scope. NodeScopeResolver has several calls to hasSideEffects
followed by invalidateExpression.
Details about this are in https://phpstan.org/blog/remembering-and-forgetting-returned-values but
the gist is that:
void functions are always impure
function returning static or $this are always impure
functions returning other types are maybe-impure
It's the last case where (if rememberPossiblyImpureFunctionValues is set to true which is the
default) default behaviour needs help by having to add @phpstan-impure above function and method
declarations.
This has two effects:
- TypeSpecifier does not remember the last-checked value of the function in the scope with the
same arguments (because it's not deterministic). - NodeScopeResolver will invalidate remembered expressions on
$objectin Scope if an impure or
void method is called on$object.
So if NodeScopeResolver encounters a possibly impure function (hasSideEffects returns maybe), it
could note this down in Scope ("this could have been an invalidation"), and when related rules
would report an error involving this expression (like calculateSomething() or $object), they
could output the suggested tip.
Best way to track this in Scope is with custom virtual expr nodes. We already do this with nodes
like IntertwinedVariableByReferenceWithExpr or PropertyInitializationExpr.
Did PHPStan help you today? Did it make you happy in any way?
No response
Activity
ondrejmirtes commentedon Feb 22, 2026
Claude's plan:
Plan: Add @phpstan-impure tips to comparison rules
Context
When
rememberPossiblyImpureFunctionValuesistrue(default), PHPStan remembers return values of possibly-impure functions/methods in conditions. This leads to "always true/false" errors that confuse users. Two scenarios:Scenario 1 - TypeSpecifier remembers a possibly-impure function's return value:
Scenario 2 - NodeScopeResolver doesn't invalidate object after maybe-impure method call:
Users need a tip suggesting
@phpstan-impureon the relevant function/method.Implementation
1. New virtual expr node:
PossiblyImpureCallExprNew file:
src/Node/Expr/PossiblyImpureCallExpr.phpimpactedExpris always non-null:callExpr(the function call itself is what's affected)$value)Exposing sub-nodes in
getSubNodeNames()ensuresshouldInvalidateExpression()(viaNodeFinder) can find the object variable inside, so the tracking entry is automatically invalidated when the object is invalidated by a definitely-impure call.2. Printer method
File:
src/Node/Printer/Printer.php3. Tracking in NodeScopeResolver
File:
src/Analyser/NodeScopeResolver.phpAdd
#[AutowiredParameter] private readonly bool $rememberPossiblyImpureFunctionValuesto constructor.3a. Method calls (~line 3216): After the
hasSideEffects()->yes()invalidation branch, add:3b. Static calls (~line 3423): Similar pattern, with
impactedExpr = new Variable('this')when the call is on the current class.3c. Function calls (~line 2905): After function processing:
Note: for function calls,
impactedExpr=callExpr= the FuncCall expression.4. Query method on MutatingScope
File:
src/Analyser/MutatingScope.phpLogic: Iterate
$this->expressionTypes, findPossiblyImpureCallExprentries. For each:callExpr's key matches any sub-node of the error expression (viaNodeFinder)impactedExpr's key matches any sub-node of the error expressionReturn unique descriptions.
5. Tip on RuleErrorBuilder
File:
src/Rules/RuleErrorBuilder.phpAdd a convenience method:
For each description, calls
addTip("If {description} has side effects, add @phpstan-impure PHPDoc tag above its declaration.").6. Configuration
File:
conf/config.neon- Addtips.possiblyImpure: trueFile:
conf/parametersSchema.neon- AddpossiblyImpure: bool()to tips schema7. Common tip helper service
New file:
src/Rules/Comparison/PossiblyImpureTipHelper.phpCreate a shared service that all comparison rules can use:
8. Update all comparison rules
Files: All ~16 rules in
src/Rules/Comparison/(excludingUsageOfVoidMatchExpressionRule)Inject
PossiblyImpureTipHelperinto each rule. In each rule's$addTipclosure, call the helper first (highest priority). If the helper adds a tip, return immediately. Otherwise, fall through to existing logic (treatPhpDocTypesAsCertain, reasons, etc.).The expression to pass varies by rule:
IfConstantConditionRule,ElseIfConstantConditionRule,WhileLoop*,DoWhileLoop*,TernaryOperator*:$node->condStrictComparisonOfDifferentTypesRule,NumberComparison*,ConstantLooseComparison*: the$node(BinaryOp)BooleanAndConstantConditionRule,BooleanOrConstantConditionRule,LogicalXor*: left/right/overall expressionBooleanNotConstantConditionRule:$node->exprImpossibleCheckType*Rule: the$node(FuncCall/MethodCall/StaticCall)MatchExpressionRule: the arm conditionPattern in each
$addTipclosure:Note: May need to add a
hasTips()method onRuleErrorBuilder(or just have the helper return a boolean indicating whether it added tips).Verification
make tests- run full test suite@phpstan-pure(hasSideEffects -> no)@phpstan-impure(hasSideEffects -> yes, error doesn't occur)rememberPossiblyImpureFunctionValues: false(error doesn't occur)Key files to modify
src/Node/Expr/PossiblyImpureCallExpr.php(new)src/Rules/Comparison/PossiblyImpureTipHelper.php(new)src/Node/Printer/Printer.phpsrc/Analyser/NodeScopeResolver.phpsrc/Analyser/MutatingScope.phpsrc/Rules/RuleErrorBuilder.phpconf/config.neon,conf/parametersSchema.neonsrc/Rules/Comparison/tests/PHPStan/Rules/Comparison/data/phpstan-bot commentedon Mar 4, 2026
Closed via merging phpstan/phpstan-src#5031
github-actions commentedon Apr 5, 2026
This thread has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs.