Skip to content

Check static method calls through constant class-name strings and class-string unions - #5976

Open
gnutix wants to merge 1 commit into
phpstan:2.3.xfrom
gnutix:static-call-string-unions
Open

gnutix wants to merge 1 commit into
phpstan:2.3.xfrom
gnutix:static-call-string-unions

Conversation

@gnutix

@gnutix gnutix commented Jul 2, 2026 •

Copy link
Copy Markdown
Contributor

Closes phpstan/phpstan#11353

Description

$classFQCN::create() was silently skipped by CallStaticMethodsRule whenever $classFQCN held a union of constant class-name strings ('Test\A'|'Test\B'|'Test\C'), a union of class-string<A>|class-string<C> (e.g. from get_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

StaticMethodCallCheck has a legacy special case that predates the modern class-string API (Type::isClassString() / Type::getClassStringObjectType()): after RuleLevelHelper::findTypeToCheck(), only $classType instanceof GenericClassStringType (a single class-string<T>) was unwrapped to its object type; every other stringy type fell into the isString()->yes() bail and returned no errors. Type inference (MutatingScope) already resolves such calls correctly, and InstantiationRule already checks new $classFQCN() for these shapes via isClassString()->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 virtual TypeExpr node. There is no separate isClassString()->yes() guard: for any type that is not entirely a class-string (objects, unions with non-class-string members, plain string), getClassStringObjectType() yields no class name, so the conversion is skipped by the same no-class-name check that keeps plain class-string and 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:

  • the type already answers hasMethod()->yes() — that preserves the class-string<T>&hasMethod(m) behavior from method_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;
  • the described object type has no class name (class-string, class-string<object>, class-string<T of object>): plain class-string stays silent and class-string<object> / class-string<T of object> keep being reported as before (Call to an undefined static method object::create());
  • the class-string names a trait: RuleLevelHelper would 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 __callStatic handling via reportMagicMethods.

Two changes to existing errors, both covered by tests:

  • an unknown class inside class-string<T> is now reported as Call to static method create() on an unknown class Foo. (class.notFound), consistently with object-typed expressions, instead of Call to an undefined static method Foo::create(). (staticMethod.notFound);
  • class-string<A|B> (and class-string<T of A|B>) now follows the object-union rules: a method missing on one member is reported only when checkUnionTypes is on (level 7+), exactly like $object::method() on an A|B object. Before, it was reported from level 2.

Tests

static-call-string-unions.php covers single constant string, constant-string unions, get_class() unions, class-string unions, single class-string<C>, plain class-string (silent), class-string<object> and class-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, __callStatic and method_exists() narrowing. It runs with checkUnionTypes on and off and with reportMagicMethods off: single class-string<C> / single constant string are reported in all variants, unions only with checkUnionTypes: 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, because StringType::canCallMethods() is No (NonObjectTypeTrait) — so with checkUnionTypes off (levels 0–6) the union-filter callback rejected every member and the union passed through whole. A naive fix (unwrapping strings after findTypeToCheck()) 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:

  • (a) inject %checkUnionTypes% into StaticMethodCallCheck and skip the multi-member case when it's off;
  • (b) convert the class-string type to its object type before findTypeToCheck(), routing it through PHPStan\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-2 e2e gets one new, correct error: its fixture calls $enumClass::isValidKey($value) with an int|string value while MyCLabs\Enum\Enum::isValidKey() declares @psalm-param string $key (the same call on an Enum object is already reported). Companion: phpstan/phpstan#15473.

Alternatives

If you prefer, either part can go the other way:

  • (a) gate unions on checkUnionTypes instead of routing through the object type. The current choice means class-string<A|B> errors are no longer reported at levels 2–6 (they follow $object::method() on A|B); option (a) would keep them.
  • (b) mirror the errors T::method() reports for a class-string naming a trait (e.g. staticMethod.notFound for a missing method) instead of staying silent.

Known follow-up candidates (separate PRs)

The same legacy instanceof GenericClassStringType + isString() bail pattern exists in AccessStaticPropertiesCheck and ClassConstantRule — 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.

@gnutix
gnutix changed the base branch from 2.1.x to 2.2.x July 4, 2026 19:48
@gnutix
gnutix force-pushed the static-call-string-unions branch 2 times, most recently from ce10bc5 to 888bf9c Compare July 5, 2026 07:49
@gnutix
gnutix force-pushed the static-call-string-unions branch 2 times, most recently from 67e8bcc to 0de3272 Compare July 5, 2026 11:39
@gnutix
gnutix force-pushed the static-call-string-unions branch from 0de3272 to e91abd3 Compare October 12, 2026 16:32
@gnutix
gnutix changed the base branch from 2.2.x to 2.3.x October 12, 2026 16:32
@gnutix
gnutix force-pushed the static-call-string-unions branch from e91abd3 to b6417d9 Compare October 12, 2026 18:33
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
…ss-string unions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@gnutix
gnutix force-pushed the static-call-string-unions branch from b6417d9 to c3f494b Compare October 12, 2026 20:27
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
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.

No warning that a method being called on a class does not exist (even though the type's class is correct)

1 participant