Skip to content

Check static property access through constant class-name strings and class-string unions - #5979

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

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

Conversation

@gnutix

@gnutix gnutix commented Jul 2, 2026 •

Copy link
Copy Markdown
Contributor

Follow-up to phpstan-src#5976 ("Check static method calls through constant class-name strings and class-string unions"), same root-cause family.

AccessStaticPropertiesCheck (the $classString::$prop path) contained a legacy string bail-out that predates the Type::isClassString() / Type::getClassStringObjectType() API: as soon as the class expression resolved to a string type it returned silently (if ($classType->isString()->yes()) { return []; }). As a result, static property access through a variable holding a constant class-name string, a class-string<C>, or a union thereof reported nothing — even when the property did not exist.

This is actually worse than the methods case that #5976 fixed: the methods code at least handled a single GenericClassStringType, whereas here even a single class-string<C> was skipped entirely. So singleGenericClassString() in the new test is a brand-new report.

Fix

Mirrors #5976, adapted to properties. Before calling RuleLevelHelper::findTypeToCheck(), the check routes through the object type the class expression describes (new TypeExpr($exprType->getClassStringObjectType())) whenever that names at least one class that is not a trait. 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). This makes constant-string / class-string unions behave identically to object-typed expressions with respect to rule levels: union-member filtering when checkUnionTypes is off, whole-union check when it is on, plus benevolent-union and unknown-class handling for free (StringType::canAccessProperties() is No, the same bypass mechanism as canCallMethods() in the template).

  • No hasStaticProperty() guard is needed (unlike Check static method calls through constant class-name strings and class-string unions #5976's hasMethod() guard): property_exists() with a string first argument narrows the property_exists(...) call itself, and the rule rebuilds that call from the original class expression, so property_exists()-guarded access keeps working (covered by propertyExistsNarrowing()).
  • Class-strings whose object type has no class name (plain class-string, class-string<object>, class-string<T of object>) are not rerouted and stay silent, as before.
  • Class-strings naming a trait are not rerouted either: RuleLevelHelper would report the trait as an unknown class even when the property exists, so $trait = T::class; $trait::$prop stays silent as before.
  • PHP has no magic static properties (no __get equivalent for statics), so unlike Check static method calls through constant class-name strings and class-string unions #5976 there are no magic-member cases and no reportMagicMethods gating.

The access now goes through the whole existing pipeline, for reads and writes alike: undefined property (including in isset(), ??, empty(), ++ and ??=), static access to an instance property, private/protected visibility, and private(set) / protected(set) on writes.

Tests

static-property-string-unions.php (read rule) mirrors the template's matrix: single constant string; union of constant strings with one member missing the property; all-valid union; get_class() union; class-string<Foo>|class-string<Baz> union; single class-string<Baz> (the new report); plain class-string (silent); static access to an instance property through a constant string; property_exists() narrowing (silent); trait name (silent); isset() / ?? / empty() on a missing property; private/protected read. Level semantics are pinned with the same flag-combination approach as #5976 — unions are reported only when checkUnionTypes is on, single class-strings always.

static-property-string-unions-assign.php (assign rule) covers writes, ++ and ??= on a missing property, a private write, a union write, a valid write and a write through a trait name (silent); static-property-string-unions-asymmetric.php (PHP 8.5) covers private(set) / protected(set) writes through a constant string.

Ecosystem impact

No integration project gains a new error from this PR.

Alternatives

If you prefer, either part can go the other way, in line with whatever is decided on #5976:

  • (a) gate unions on checkUnionTypes instead of routing through the object type; with the current choice, class-string unions follow the object-union rules (reported at level 7+ only).
  • (b) mirror what T::$prop reports for a class-string naming a trait (e.g. staticProperty.notFound for a missing property) instead of staying silent.

🤖 Generated with Claude Code, model Fable 5.

@gnutix
gnutix changed the base branch from 2.1.x to 2.2.x July 4, 2026 20:00
@gnutix
gnutix force-pushed the static-property-string-unions branch 2 times, most recently from ed541a4 to d23e01f Compare July 5, 2026 07:49
@gnutix
gnutix force-pushed the static-property-string-unions branch 3 times, most recently from fbdd0e9 to dfcb186 Compare July 5, 2026 11:40
@gnutix
gnutix force-pushed the static-property-string-unions branch from dfcb186 to 1514045 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-property-string-unions branch from 1514045 to 915568f Compare October 12, 2026 18:33
…class-string unions

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

1 participant