Repository navigation
Conversation
gnutix
force-pushed
the
static-call-string-unions
branch
2 times, most recently
from
July 5, 2026 07:49
ce10bc5 to
888bf9c
Compare
gnutix
force-pushed
the
static-call-string-unions
branch
2 times, most recently
from
July 5, 2026 11:39
67e8bcc to
0de3272
Compare
gnutix
force-pushed
the
static-call-string-unions
branch
from
October 12, 2026 16:32
0de3272 to
e91abd3
Compare
gnutix
force-pushed
the
static-call-string-unions
branch
from
October 12, 2026 18:33
e91abd3 to
b6417d9
Compare
gnutix
added a commit
to gnutix/phpstan
that referenced
this pull request
Oct 12, 2026
…dator phpstan/phpstan-src#5976 checks static method calls through class-string unions. EnumValidator calls $enumClass::isValidKey($value) with an int|string value on a class-string<Enum<int>>|class-string<Enum<string>>, while MyCLabs\Enum\Enum::isValidKey() declares @psalm-param string $key. The same call on an Enum object is already reported, so this is a true positive in the fixture; reportUnmatched: false keeps the e2e green before and after. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QW8VaDtn3cMdQowPSh1xGG
This was referenced Oct 12, 2026
…ss-string unions Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
gnutix
force-pushed
the
static-call-string-unions
branch
from
October 12, 2026 20:27
b6417d9 to
c3f494b
Compare
gnutix
added a commit
to gnutix/phpstan
that referenced
this pull request
Oct 13, 2026
phpstan/phpstan-src#5976 checks static calls through class-string unions, so $enumClass::isValidKey($value) with int|string $value is now reported against the method's @PARAM string $key. Casting keeps the lookup identical, since array keys normalise numeric strings to ints. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QW8VaDtn3cMdQowPSh1xGG
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Closes phpstan/phpstan#11353
Description
$classFQCN::create()was silently skipped byCallStaticMethodsRulewhenever$classFQCNheld a union of constant class-name strings ('Test\A'|'Test\B'|'Test\C'), a union ofclass-string<A>|class-string<C>(e.g. fromget_class()), or even a single constant class-name string in a variable — no error even when one of the classes has no such method.Root cause
StaticMethodCallCheckhas a legacy special case that predates the modern class-string API (Type::isClassString()/Type::getClassStringObjectType()): afterRuleLevelHelper::findTypeToCheck(), only$classType instanceof GenericClassStringType(a singleclass-string<T>) was unwrapped to its object type; every other stringy type fell into theisString()->yes()bail and returned no errors. Type inference (MutatingScope) already resolves such calls correctly, andInstantiationRulealready checksnew $classFQCN()for these shapes viaisClassString()->yes()+getClassStringObjectType()— only the static method call rule was left behind.Fix
Before calling
RuleLevelHelper::findTypeToCheck(), convert the expression's type to the object type it describes (getClassStringObjectType()) and, when that names at least one class that is not a trait, route the check through a virtualTypeExprnode. There is no separateisClassString()->yes()guard: for any type that is not entirely a class-string (objects, unions with non-class-string members, plainstring),getClassStringObjectType()yields no class name, so the conversion is skipped by the same no-class-name check that keeps plainclass-stringand traits on the old path (a guard would be an equivalent mutant for Infection).The conversion is skipped, and the existing string code path is kept unchanged, in three cases:
hasMethod()->yes()— that preserves theclass-string<T>&hasMethod(m)behavior frommethod_exists()narrowing (Parameter closure type extensions for array_map/filter/walk/find + native flavour resolution #6147), where the dummy method reflection would otherwise produce a bogus "static call to instance method" error;class-string,class-string<object>,class-string<T of object>): plainclass-stringstays silent andclass-string<object>/class-string<T of object>keep being reported as before (Call to an undefined static method object::create());RuleLevelHelperwould report it as an unknown class even when the method exists, so$trait = T::class; $trait::method();stays silent as before.Calls through constant class-name strings now also get the rest of the check pipeline: "static call to instance method", private/protected visibility, and
__callStatichandling viareportMagicMethods.Two changes to existing errors, both covered by tests:
class-string<T>is now reported asCall to static method create() on an unknown class Foo.(class.notFound), consistently with object-typed expressions, instead ofCall to an undefined static method Foo::create().(staticMethod.notFound);class-string<A|B>(andclass-string<T of A|B>) now follows the object-union rules: a method missing on one member is reported only whencheckUnionTypesis on (level 7+), exactly like$object::method()on anA|Bobject. Before, it was reported from level 2.Tests
static-call-string-unions.phpcovers single constant string, constant-string unions,get_class()unions,class-stringunions, singleclass-string<C>, plainclass-string(silent),class-string<object>andclass-string<T of object>(reported),class-string<Foo|Baz>(union semantics), trait name (silent), private/protected through a constant string,class-string<UnknownClass>, static call to an instance method,__callStaticandmethod_exists()narrowing. It runs withcheckUnionTypeson and off and withreportMagicMethodsoff: singleclass-string<C>/ single constant string are reported in all variants, unions only withcheckUnionTypes: true.Question about rule-level semantics (@ondrejmirtes)
One design point I'd like your input on: constant-string unions used to bypass
RuleLevelHelper's union member filtering entirely, becauseStringType::canCallMethods()isNo(NonObjectTypeTrait) — so withcheckUnionTypesoff (levels 0–6) the union-filter callback rejected every member and the union passed through whole. A naive fix (unwrapping strings afterfindTypeToCheck()) would therefore have reported union errors even at level 0, breaking the "unions reported at level 7+" contract that object unions follow.I considered two options:
%checkUnionTypes%intoStaticMethodCallCheckand skip the multi-member case when it's off;findTypeToCheck(), routing it throughPHPStan\Node\Expr\TypeExpr, so member filtering, benevolent unions, and unknown-class handling behave identically to object unions.I implemented (b) because it reuses the existing level machinery instead of duplicating its logic (error line attribution on the virtual node comes out correct — verified by tests).
Ecosystem impact
The
undiscoverable-symbols-2e2e gets one new, correct error: its fixture calls$enumClass::isValidKey($value)with anint|stringvalue whileMyCLabs\Enum\Enum::isValidKey()declares@psalm-param string $key(the same call on anEnumobject is already reported). Companion: phpstan/phpstan#15473.Alternatives
If you prefer, either part can go the other way:
checkUnionTypesinstead of routing through the object type. The current choice meansclass-string<A|B>errors are no longer reported at levels 2–6 (they follow$object::method()onA|B); option (a) would keep them.T::method()reports for a class-string naming a trait (e.g.staticMethod.notFoundfor a missing method) instead of staying silent.Known follow-up candidates (separate PRs)
The same legacy
instanceof GenericClassStringType+isString()bail pattern exists inAccessStaticPropertiesCheckandClassConstantRule— static property fetches and class constant accesses through constant-string/class-string unions are silently skipped the same way (#5979, #5980).Companion PR (integration/e2e expectation, merge together): phpstan/phpstan#15478
🤖 Generated with Claude Code, model Fable 5.