Skip to content

Skip __toString purity check in ImplicitToStringCallHelper when type is not an object#5511

Open
phpstan-bot wants to merge 5 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-6lgz7pw
Open

Skip __toString purity check in ImplicitToStringCallHelper when type is not an object#5511
phpstan-bot wants to merge 5 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-6lgz7pw

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

When a value has an intersection type like int&T (where T is a template type bounded by mixed), casting it to string with (string) was incorrectly reported as "Possibly impure call to method stdClass::__toString()" inside @phpstan-pure functions. The string cast on non-object types like int is a built-in PHP operation that does not invoke __toString(), so no impure point should be generated.

Changes

  • Added an early return in src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php when $exprType->isObject()->no(). Since __toString() can only be called on objects, non-object types (scalars, null) use PHP's built-in string conversion which is always pure.
  • For intersection types like int&T, isObject() correctly evaluates to No via TrinaryLogic::lazyMaxMin (int says No, template-mixed says Maybe → result is No), so the check is skipped.
  • This fix applies to all callers of ImplicitToStringCallHelper: string casts ((string)), string concatenation (.), echo, print, interpolated strings ("$x"), and .= assignment.

Analogous cases probed and found correct

  • General method calls on intersection types (e.g., PureInterface&ImpureInterface): IntersectionTypeMethodReflection::isPure() already uses TrinaryLogic::lazyMaxMin which returns Yes if any member says Yes. No fix needed.
  • Other magic methods (__clone, __invoke): Not handled via ImplicitToStringCallHelper, and their handlers don't have the same issue.

Root cause

ImplicitToStringCallHelper::processImplicitToStringCall() unconditionally looked for a __toString() method on the expression type, even when the type was definitively not an object. For int&T, IntegerType doesn't have __toString (uses NonObjectTypeTrait), but TemplateMixedType inherits MixedType::hasMethod() which returns Yes for all methods. The intersection's hasMethod('__toString') returned Yes, so only T's (impure) method reflection was found, causing the false positive.

Test

  • tests/PHPStan/Rules/Pure/data/bug-14511.php — regression test for PureFunctionRule covering string cast on int&T, string concatenation on int&T, and string casts on float&T, bool&T, string&T
  • tests/PHPStan/Rules/Pure/data/bug-14511-method.php — regression test for PureMethodRule covering the same patterns in a method context

Fixes phpstan/phpstan#14511

…ype is not an object

- Add early return in `ImplicitToStringCallHelper::processImplicitToStringCall()`
  when `$exprType->isObject()->no()`, since string cast on non-object types (int,
  float, bool, string) is a built-in operation that does not call `__toString()`
- For intersection types like `int&T` (where T is a template type), `isObject()`
  correctly returns No because int is not an object, preventing a false
  "possibly impure call to method stdClass::__toString()" error
- This also prevents unnecessary throw points from being generated for
  `__toString` on non-object types
- Verified that the general method call case on intersection types (where both
  types have the method) is already handled correctly by
  `IntersectionTypeMethodReflection::isPure()` using `TrinaryLogic::lazyMaxMin`
- Added regression tests for both PureFunctionRule and PureMethodRule covering
  string cast, string concatenation, and multiple scalar types (int, float, bool,
  string) intersected with template types
Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the fix be on the IntersectionType instead ?

…od()

Instead of guarding against non-object types in ImplicitToStringCallHelper,
fix the root cause in IntersectionType::hasMethod(). When any type in the
intersection is definitively not an object and definitively doesn't have
the method, the intersection should return No.

This prevents MixedType::hasMethod() (which returns Yes for all methods)
from dominating via TrinaryLogic::lazyMaxMin's early-return-on-Yes behavior
when intersected with concrete non-object types like int.

The check is targeted: class-string types return Maybe (not No) from
hasMethod() for methods that might exist on the referenced class, so
the guard doesn't trigger for class-string intersections.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Pushed. Here's a summary of the change:

What changed: Moved the fix from ImplicitToStringCallHelper to IntersectionType::hasMethod(), addressing VincentLanglet's review comment.

The root cause: TrinaryLogic::lazyMaxMin early-returns Yes when any type says Yes. For int&T (T bounded by mixed), MixedType::hasMethod('__toString') returns Yes, which dominates IntegerType's No via this early return.

The fix: In IntersectionType::hasMethod(), before calling intersectResults, check if any individual type is definitively not an object (isObject()->no()) AND definitively doesn't have the method (hasMethod()->no()). If so, the intersection can't have the method either.

Why this is targeted enough: class-string<S> types return Maybe (not No) from hasMethod() because the referenced class might have the method. So the guard doesn't trigger for class-string intersections, preserving correct method_exists() behavior.

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@template T of int|Stringable already works fine and is_int check refine the type to

T of int (function test2(), argument)

So why for T of mixed, the type is not refined and stay int&T (function test2(), argument) ?

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

The branch is already up to date with no new changes needed. The reviewer's comment is a question about type narrowing, not a request for a code change.

Here's my analysis for the PR response:

Why the types differ:

For @template T of int|Stringable, the template type is TemplateUnionType which extends UnionType. When TypeCombinator::intersect() processes TemplateUnionType & int, it triggers the union distribution logic (lines 1254-1294 of TypeCombinator.php): int & (int|Stringable) becomes (int & int) | (int & Stringable) = int | never = int, then re-wraps via TemplateTypeFactory::create() producing T of int.

For @template T of mixed, the template type is TemplateMixedType which extends MixedType (not UnionType). The union distribution doesn't trigger. In the pairwise comparison, TemplateMixedType::isSuperTypeOf(IntegerType) returns Maybe (via TemplateTypeTrait which ands the bound's result with Maybe), so neither type gets eliminated and the intersection int&T is preserved.

Why not fix it in TypeCombinator: Changing TypeCombinator to produce narrowed template types (T of int instead of int&T) would affect all TemplateMixedType intersections globally. I found it would impact existing tests (bug-4498.php, bug-4117.php, error messages in CallMethodsRuleTest). Additionally, TemplateTypeFactory::create() doesn't support all types as bounds (e.g., ConstantBooleanType, ConstantFloatType fall through to TemplateMixedType), creating inconsistent representations within the same union. This makes it a much larger and riskier change.

Why the IntersectionType::hasMethod() fix is correct: The guard checks both isObject()->no() AND hasMethod($methodName)->no(), ensuring it only triggers when a type definitively cannot have methods (non-object scalars). It doesn't affect class-string types (which return Maybe for hasMethod()), preserving method_exists() behavior.

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are they similar issue for other method of the IntersectionType ?

…taticProperty, hasConstant

The same lazyMaxMin early-return issue from hasMethod() affects these
methods: MixedType returns Yes for all of them, which dominates a
non-object type's No via the early return in lazyMaxMin. This causes
incorrect Yes results for intersection types like int&T where T is
a template bounded by mixed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Pushed. Here's the analysis:

Yes, the same issue exists for other member-existence methods in IntersectionType. The root cause is TrinaryLogic::lazyMaxMin which early-returns Yes when any type says Yes. Since MixedType returns Yes for hasProperty(), hasInstanceProperty(), hasStaticProperty(), and hasConstant(), these methods incorrectly return Yes for intersection types like int&T (where T is bounded by mixed), even though IntegerType (via NonObjectTypeTrait) definitively returns No.

What was fixed: Applied the same guard pattern from hasMethod() to:

  • hasProperty() — prevents false "has property" for non-object intersections
  • hasInstanceProperty() — same
  • hasStaticProperty() — same
  • hasConstant() — prevents false "has constant" for non-object intersections

Methods NOT changed (and why):

  • canCallMethods(), canAccessProperties(), canAccessConstants(), isCloneable() — these have the same theoretical issue (MixedType returns Yes, NonObjectTypeTrait returns No), but they are general capability checks rather than specific member lookups. Changing them could alter error reporting behavior more broadly (e.g., different error messages). The has* member guards are sufficient to prevent the downstream false positives, since code typically checks has* before resolving members.

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This duplicates call like hasProperty in both the foreach and the intersectResults.

Is there a smarter way to compute this ? Should we introduce a new private method ?

…calls

The non-object guard and intersectResults were calling hasMethod/hasProperty/etc.
twice per type. A single-pass private method now computes each result once,
checks the guard, and feeds pre-computed results to TrinaryLogic::maxMin.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Pushed. The refactoring extracts a new intersectResultsWithNonObjectGuard() private method that does a single pass through the types: it computes each type's result once, checks the non-object guard during that pass, and uses TrinaryLogic::maxMin() on the pre-computed results. This eliminates the duplicate has* calls that the reviewer flagged. All tests and static analysis pass.

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like we're basically looking for a hacky fix because we're playing with kinda int&mixed type.

List other ideas

  • Should MixedType be skipped in those method ?
  • Should int&T be resolved differently ?
  • Do you have suggestions ?

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

The token doesn't have permissions to comment on the PR directly. The instructions mention results will be posted in a separate step. Here's my analysis for the PR response:


Analysis of reviewer's suggested alternatives

I thoroughly investigated all three suggestions. No code changes are needed — the current intersectResultsWithNonObjectGuard is the correct approach. Here's why:

1. "Should MixedType be skipped in those methods?"

Changing MixedType::hasMethod() from Yes to Maybe would naturally fix the intersection issue — for int&T, lazyMaxMin(No, Maybe) = No. But it causes widespread breakage: all 18+ rules and MutatingScope::filterTypeWithMethod() check ->hasMethod()->yes(), so changing MixedType's answer would break method resolution on standalone mixed types across the entire system.

2. "Should int&T be resolved differently?"

For @template T of int|Stringable, TypeCombinator::intersect() triggers union distribution, producing T of int. For @template T of mixed, TemplateMixedType extends MixedType (not UnionType), so distribution doesn't trigger and int&T is preserved. Changing this would affect all TemplateMixedType intersections globally.

3. Why not a simpler isObject()->no() guard?

I tried replacing intersectResultsWithNonObjectGuard with if ($this->isObject()->no()) return No at the top of each has* method. This is cleaner but too aggressive — it breaks class-string<S> intersection types (2 test failures).

The key: GenericClassStringType is a non-object (isObject()->no()) but returns Maybe for hasMethod() via StringType::hasMethod():

if ($this->isClassString()->yes()) {
    return TrinaryLogic::createMaybe(); // subclass might have the method
}

For class-string<S>&literal-string with method_exists($s, 'nonExistent'):

  • isObject()->no() guard: returns No → incorrectly reports method_exists as always false, even for non-final classes
  • intersectResultsWithNonObjectGuard: checks isObject()->no() && $result->no() per-type → GenericClassStringType returns Maybe, so the guard doesn't trigger, correctly preserving Maybe

Conclusion

The intersectResultsWithNonObjectGuard is a precise semantic rule: "In an intersection, if any type is definitively not an object AND definitively doesn't have the member (No, not Maybe), the intersection can't have the member." IntegerType (No + No) triggers the guard; GenericClassStringType (No + Maybe) does not. This is the narrowest correct fix.

All tests (11880) pass and static analysis reports no errors.

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Revert the changes inside IntersectionType and modify TypeCombinator::intersect to narrow the TemplateMixedType instead

int&T

will become T of int.

Update the needed test to show how big is the impact

Revert IntersectionType changes and instead narrow TemplateMixedType
to a concrete template type (e.g. TemplateIntegerType) when intersected
with a scalar type in TypeCombinator::intersect().

For `int & T` where T is TemplateMixedType, the result is now
`T of int` (TemplateIntegerType) instead of an IntersectionType.
Since TemplateIntegerType delegates hasMethod() to IntegerType (which
returns No for __toString), the false positive purity error is avoided.

The narrowing is restricted to scalar types only. Narrowing object
or array types would cause information loss during template resolution
(e.g. `T&MockObject` becoming just the resolved type without MockObject).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Pushed. Here's a summary of the changes:

What changed: Reverted all IntersectionType changes and moved the fix to TypeCombinator::intersect(), narrowing TemplateMixedType to a concrete template type when intersected with a scalar.

How it works: When TypeCombinator::intersect() encounters int & T (where T is TemplateMixedType), it uses TemplateTypeFactory::create() to produce T of int (TemplateIntegerType). Since TemplateIntegerType delegates hasMethod('__toString') to IntegerType (which returns No), the false positive purity error is avoided.

Why scalar-only: I initially tried narrowing for all types but found it causes information loss during template resolution:

  • T&MockObject in @return T&MockObject narrowed to T of MockObject → when T resolves to stdClass, MockObject is lost → 43 PHPStan errors in test files (all mock-related)
  • T&list<...> lost the template identity entirely
  • T&Dolor in @return T&Dolor lost the never-detection when T resolves to an unrelated class

Scalar types don't have this problem because they have no methods/properties/constants that would be lost, and the original bug is specifically about scalar types (is_int, is_float, is_bool, is_string narrowing).

Test impact (3 files):

  • bug-4117.php: 0&T, ''&T, '0'&T now shown as T of 0, T of '', T of '0' (constant scalars narrowed)
  • bug-4498.php: (int&TKey)|(string&TKey)TKey of int|TKey of string (key type narrowed)
  • CallToFunctionParametersRuleTest: Same pattern as bug-4498 in error messages

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why ->isScalar()->yes() check is needed ?

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

All 11880 tests pass and static analysis reports no errors. The reviewer's comment is a question, not a code change request. No changes are needed.

Here's the analysis for the PR response:


Why ->isScalar()->yes() is needed:

Removing the scalar guard causes 10 test failures across two categories:

1. Object intersection identity loss (8 failures): T & MockObject means the value is both T and a MockObject — these are independent constraints. Narrowing to T of MockObject (meaning "T is some subtype of MockObject") loses the dual identity. This breaks phpunit mock patterns where createMock() returns T&MockObject, and the caller needs both the original class interface and MockObject's methods. Examples: bug-4557.php (MockObject&stdClass becomes just stdClass), bug-6635.php (A&T after instanceof becomes T of A), instanceof.php (object&T becomes T of object).

2. Unresolvable type detection failure (2 failures): For T&Dolor where T resolves to an unrelated class Sit, the intersection should resolve to never. If prematurely narrowed to T of Dolor, this never-detection breaks — the errors about unresolvable return types disappear (false negatives in generic-return-type-never.php).

Why scalars are safe to narrow: Scalar types (int, string, float, bool) cannot form meaningful intersections — you can't call methods, access properties, or further refine them with instanceof. T & int is semantically equivalent to T of int. For objects and arrays, the intersection carries information that a narrowed bound cannot express.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants