Skip to content

Look up object class names in nullable and mixed unions and class-strings in restricted-usage rules - #7006

Merged
VincentLanglet merged 4 commits into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-19je90i
Nov 8, 2026
Merged

VincentLanglet merged 4 commits into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-19je90i

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

Restricted-usage extensions (deprecations, @internal, etc.) were not called for members accessed on a nullable object type. For example, $requestStack->getCurrentRequest()->get() where getCurrentRequest() returns ?Request was not reported. $nullable?->get() was reported. The same gap existed for other non-object union members (Foo|false, Foo|int), for first-class callables and string casts, and for static access, class constants and static properties on ?Foo and on class-string<Foo>.

Changes

  • New src/Rules/RestrictedUsage/RestrictedUsageObjectClassNames.php:
    • get(Type): returns getObjectClassNames(). If that list is empty and the type may be an object, it intersects the type with object and reads the class names from the result.
    • getForStaticAccess(Type): removes null, applies getObjectTypeOrClassStringObjectType() so class-strings resolve to their object type, then calls get().
  • Instance access uses get(): RestrictedMethodUsageRule, RestrictedPropertyUsageRule, RestrictedMethodCallableUsageRule, RestrictedUsageOfDeprecatedStringCastRule.
  • Static access (::) uses getForStaticAccess() on the type found by RuleLevelHelper: RestrictedStaticMethodUsageRule, RestrictedStaticMethodCallableUsageRule, RestrictedStaticPropertyUsageRule, RestrictedClassConstantUsageRule.

Root cause

UnionType::getObjectClassNames() returns an empty list as soon as one member is not an object, so Request|null produced no class names and the extensions were never called. The static rules used FoundTypeResult::getReferencedClasses(), which comes from the same getObjectClassNames() call. That list is also empty for class-string<Foo>, so $className::deprecatedMethod() was never reported either.

Test

tests/PHPStan/Rules/RestrictedUsage/data/bug-15681.php has a testBug15681 case in each of the eight restricted-usage rule tests. It covers:

  • method calls on ?Foo, on Foo|false, Foo|Other|null and Foo|int, and on a ?Foo-returning method call
  • property fetches on the same kinds of types
  • first-class callables
  • (string) casts
  • static method calls, static callables, static properties and class constants on ?Foo, class-string<Foo> and class-string<Foo>|null

All eight tests fail without the fix.

Fixes phpstan/phpstan#15681

…ings in restricted-usage rules

- Add RestrictedUsageObjectClassNames helper: when Type::getObjectClassNames()
  is empty for a type that may be an object (e.g. Foo|null, Foo|false,
  Foo|int), intersect it with `object` first so the class names of the
  object members are found.
- Use it in RestrictedMethodUsageRule, RestrictedPropertyUsageRule,
  RestrictedMethodCallableUsageRule and RestrictedUsageOfDeprecatedStringCastRule.
- For the left side of `::` (RestrictedStaticMethodUsageRule,
  RestrictedStaticMethodCallableUsageRule, RestrictedStaticPropertyUsageRule,
  RestrictedClassConstantUsageRule), remove null and resolve class-strings
  with getObjectTypeOrClassStringObjectType(). Previously these used the
  FoundTypeResult referenced classes, which were empty for ?Foo as well as
  for class-string<Foo>.
Drop the RestrictedUsageObjectClassNames helper and use
TypeCombinator::intersect() with ObjectWithoutClassType directly,
like the rest of the codebase does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@VincentLanglet
VincentLanglet requested a review from staabm November 8, 2026 16:26
Comment on lines +76 to +79
$referencedClasses = TypeCombinator::intersect(
TypeCombinator::removeNull($classTypeResult->getType())->getObjectTypeOrClassStringObjectType(),
new ObjectWithoutClassType(),
)->getObjectClassNames();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

can this be simplified to $referencedClasses = TypeCombinator::removeNull($classTypeResult->getType())->getObjectTypeOrClassStringObjectType()->getObjectClassNames() ?

alternatively we need a test-case which shows it needs to stay like that.

same applies to other similar changes in this PR.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I made your simplification in all four static-access rules: RestrictedStaticMethodUsageRule, RestrictedStaticMethodCallableUsageRule, RestrictedStaticPropertyUsageRule and RestrictedClassConstantUsageRule. Each now uses:

$referencedClasses = TypeCombinator::removeNull($classTypeResult->getType())->getObjectTypeOrClassStringObjectType()->getObjectClassNames();

The intersection with object did nothing in these rules. They get their type from RuleLevelHelper::findTypeToCheck(), which already drops union members that don't have the member being accessed. I checked this outside the test suite with S|int, S|false and class-string<S>|int: the old and new versions report exactly the same errors.

The four instance-access rules (method call, property fetch, method callable, string cast) still need the intersection, so I left them unchanged. There the type comes straight from $scope->getType() with no filtering. Without the intersection the code would be the original $type->getObjectClassNames(), which returns an empty list for Foo|null, and that empty list is the bug itself. The existing testBug15681 cases for those rules (?Request, Request|false, Request|int, and the getCurrentRequest()->doFoo() chain) already show this: they fail without the intersection.

The change is committed as 2e262ac and not pushed. make tests passes (23,546 tests, 108 skipped), and make phpstan and make cs are clean.

RuleLevelHelper already filters out union members without the accessed
member, so intersecting with object is unnecessary there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
}

$referencedClasses = $classTypeResult->getReferencedClasses();
$referencedClasses = TypeCombinator::removeNull($classTypeResult->getType())->getObjectTypeOrClassStringObjectType()->getObjectClassNames();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

can this be simplified to $referencedClasses = TypeCombinator::removeNull($classTypeResult->getType())->getReferencedClasses();?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

no, I was wrong.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I didn't change any code, and I didn't make a new commit or rerun the tests for this round. In the comment on RestrictedStaticMethodCallableUsageRule.php, staabm took back the suggestion to use TypeCombinator::removeNull(...)->getReferencedClasses() ("no, I was wrong"), and has since approved the PR. The code is the same as at commit 2e262ac, which the last round's make tests, make phpstan and make cs passed on.

The suggestion wouldn't have worked anyway. getReferencedClasses() returns every class mentioned anywhere in a type, including generic type arguments, so it's not the same as the class you access a member on. It also doesn't resolve class-string<Foo> to Foo the way getObjectTypeOrClassStringObjectType() does, so $className::doFoo() with a class-string<StaticRequest> would stop being checked.

All four static-access rules still use:

$referencedClasses = TypeCombinator::removeNull($classTypeResult->getType())->getObjectTypeOrClassStringObjectType()->getObjectClassNames();

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I kept getObjectTypeOrClassStringObjectType()->getObjectClassNames() and didn't switch to getReferencedClasses(). As you suggested earlier, I added a test case that shows why the current code needs to stay.

Why getReferencedClasses() doesn't work here: it doesn't only return the class whose member is being accessed. It returns every class mentioned anywhere in the type, including generic type arguments. GenericObjectType::getReferencedClasses() adds the referenced classes of each type argument, and GenericClassStringType passes the call through to its inner type. For GenericStaticRequest<StaticRequest>|null, it returns both GenericStaticRequest and StaticRequest, so the rules would also check StaticRequest::doFoo(), a class that isn't being called.

New test case: I added testGeneric() to tests/PHPStan/Rules/RestrictedUsage/data/bug-15681.php. It does static method calls, static callables, static property reads and class constant reads on:

  • GenericStaticRequest<StaticRequest>|null
  • class-string<GenericStaticRequest<StaticRequest>>

StaticRequest also has a doFoo() method, a $foo property and a FOO constant, which makes the difference visible. I added the expected errors to the four static-rule tests.

I ran it both ways:

  • With your suggested change: each new line is reported twice, once for GenericStaticRequest and once for StaticRequest. In RestrictedStaticMethodUsageRuleTest, lines 115 and 120 each showed two Cannot call doFoo errors.
  • With the current code: each line is reported once, which is correct.

make tests passes (23,546 tests, 108 skipped), and make phpstan and make cs are clean. The test is committed as 4914ca9 and not pushed.

getReferencedClasses() would also return the generic type arguments,
so the same member would be checked on unrelated classes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ondrejmirtes

Copy link
Copy Markdown
Member

👍

@VincentLanglet
VincentLanglet merged commit 2af9610 into phpstan:2.3.x Nov 8, 2026
887 of 893 checks passed
@VincentLanglet
VincentLanglet deleted the create-pull-request/patch-19je90i branch November 8, 2026 19:25
@bbrala

bbrala commented Nov 9, 2026

Copy link
Copy Markdown

I noticed a few things whil working on this, i ddin push before you fixed it xD See references pr

ondrejmirtes added a commit to phpstan/phpstan that referenced this pull request Nov 9, 2026
phpunit and drupal: phpstan/phpstan-src#7006 now runs restricted-usage
checks on nullable receivers, so calls on ?Driver and
\JsonSchema\Validator|null are reported.

symfony: doctrine/persistence 4.3.x-dev deprecated
ClassMetadataFactory::hasMetadataFor() (doctrine/persistence#533).

Amp-Thread-ID: https://ampcode.com/threads/T-01a19770-5331-74b2-963f-e324ae0f7635
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.

Deprecated method and property use is not reported when the object type is nullable

5 participants