You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
A method that declares its own @phpstan-pure or @phpstan-impure inherited the parent's @pure-unless-callable-is-impure / @pure-unless-parameter-passed tags through ResolvedPhpDocBlock::merge(), and FunctionPurityCheck followed the inherited tag instead of the method's own one:
interface Replacer
{
/** * @param callable(string): string $cb * @pure-unless-callable-is-impure $cb */publicfunctionmap(callable$cb, string$subject): string;
}
finalclass ImpureReplacer implements Replacer
{
/** @phpstan-impure */publicfunctionmap(callable$cb, string$subject): string
{
echo$subject; // Impure echo in pure method ImpureReplacer::map().return$cb($subject);
}
}
finalclass PureReplacer implements Replacer
{
/** @phpstan-pure */publicfunctionmap(callable$cb, string$subject): string
{
return$cb($subject); // not reported, while callers treat the method as pure
}
}
merge() lets a method's own @phpstan-pure / @phpstan-impure win over the parent's (mergePureTags()), but it merged the @pure-unless-* tags next to the method's own purity tag. It now inherits them only into a method that declares neither @phpstan-pure nor @phpstan-impure. A method with its own purity tag keeps the @pure-unless-* tags written in its own docblock.
Effect
An @phpstan-impure override of a conditionally pure method is impure throughout. PHPStan no longer reports its body as "Impure ... in pure method" and applies the impure checks instead, so a final method without side effects gets impureMethod.pure. With bleeding edge, MethodSignatureRule reports the override as method.impureOverridePureUnlessCallable / method.impureOverridePureUnlessParameterPassed. Without bleeding edge, PHPStan doesn't report the override, the same as an @phpstan-impure override of a @phpstan-pure method today.
A @phpstan-pure override of a conditionally pure method is pure throughout. Callers treated it as pure before as well, since SimpleImpurePoint doesn't consult the conditional tags of a method whose hasSideEffects() is no, but its body could call $cb() and write to the by-ref parameter without an error. PHPStan reports those now (possiblyImpure.functionCall, pureMethod.parameterByRef). Code that relied on the exemption should drop @phpstan-pure and inherit the parent's tag, or write the tag on the method.
An override without a purity tag behaves as before: it inherits the tags from the parent class, interface, trait or stub, with parameter-name remapping, and PHPStan checks its body as conditionally pure.
Call sites behave as before: a call through the interface stays conditionally pure, and a call through the impure class stays impure.
Together with #6667, the precedence is: the method's own @phpstan-pure / @phpstan-impure, then a @pure-unless-* tag written on or inherited by the method, then the class-level @phpstan-all-methods-pure / @phpstan-all-methods-impure.
Not covered here: a docblock with both @phpstan-impure and a @pure-unless-* tag keeps both, and PHPStan still reports its body as "Impure ... in pure method". That docblock contradicts itself and should get its own error in a follow-up.
Tests
PureMethodRuleTest::testPureUnlessImpureOverride covers both tags with an impure override with and without side effects, a pure override (new errors on its body), and an override without a purity tag (body still checked).
zonuexe
changed the title
Do not check the body of an impure method against an inherited @pure-unless-* tag
Do not inherit @pure-unless-* tags into a method marked @phpstan-pure or @phpstan-impure
Oct 4, 2026
@VincentLanglet Agreed: ImpureReplacer::replace() breaks the promise of Replacer::replace(), and the example is a contract violation on purpose. The question is which error PHPStan should report for it. "Impure echo in pure method ImpureReplacer::replace()" calls the method pure while its docblock says @phpstan-impure and isPure() is no for every caller, and the error disappears once the body has no visible impure point. The break is in the signature, and MethodSignatureRule reports it as method.impureOverridePureUnlessParameterPassed (#6666).
That rule is behind reportMethodPurityOverride, so without bleeding edge this change leaves the override unreported. An @phpstan-impure override of a @phpstan-pure method behaves the same today: the child's own tag wins in ResolvedPhpDocBlock::merge(), PHPStan doesn't check the body for purity, and method.impure needs bleeding edge. Call sites don't change: a call through Replacer stays conditionally pure, and a call through ImpureReplacer is impure.
I reworked the fix. The first version skipped the body check in FunctionPurityCheck, but I traced the cause to the merged docblock: merge() put the @pure-unless-* tags next to the child's own @phpstan-pure or @phpstan-impure. The same leak let a @phpstan-pure child inherit @pure-unless-callable-is-impure, so its body could call $cb() without an error while callers treated it as pure. The fix is in ResolvedPhpDocBlock::merge() now: a method with its own @phpstan-pure or @phpstan-impure doesn't inherit the parent's @pure-unless-* tags. The test covers both directions, and the @phpstan-pure child gets errors on its body.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A method that declares its own
@phpstan-pureor@phpstan-impureinherited the parent's@pure-unless-callable-is-impure/@pure-unless-parameter-passedtags throughResolvedPhpDocBlock::merge(), andFunctionPurityCheckfollowed the inherited tag instead of the method's own one:merge()lets a method's own@phpstan-pure/@phpstan-impurewin over the parent's (mergePureTags()), but it merged the@pure-unless-*tags next to the method's own purity tag. It now inherits them only into a method that declares neither@phpstan-purenor@phpstan-impure. A method with its own purity tag keeps the@pure-unless-*tags written in its own docblock.Effect
@phpstan-impureoverride of a conditionally pure method is impure throughout. PHPStan no longer reports its body as "Impure ... in pure method" and applies the impure checks instead, so a final method without side effects getsimpureMethod.pure. With bleeding edge,MethodSignatureRulereports the override asmethod.impureOverridePureUnlessCallable/method.impureOverridePureUnlessParameterPassed. Without bleeding edge, PHPStan doesn't report the override, the same as an@phpstan-impureoverride of a@phpstan-puremethod today.@phpstan-pureoverride of a conditionally pure method is pure throughout. Callers treated it as pure before as well, sinceSimpleImpurePointdoesn't consult the conditional tags of a method whosehasSideEffects()is no, but its body could call$cb()and write to the by-ref parameter without an error. PHPStan reports those now (possiblyImpure.functionCall,pureMethod.parameterByRef). Code that relied on the exemption should drop@phpstan-pureand inherit the parent's tag, or write the tag on the method.Together with #6667, the precedence is: the method's own
@phpstan-pure/@phpstan-impure, then a@pure-unless-*tag written on or inherited by the method, then the class-level@phpstan-all-methods-pure/@phpstan-all-methods-impure.Not covered here: a docblock with both
@phpstan-impureand a@pure-unless-*tag keeps both, and PHPStan still reports its body as "Impure ... in pure method". That docblock contradicts itself and should get its own error in a follow-up.Tests
PureMethodRuleTest::testPureUnlessImpureOverridecovers both tags with an impure override with and without side effects, a pure override (new errors on its body), and an override without a purity tag (body still checked).