Skip to content

Make ReflectionObject, RecursiveFilterIterator, ParentIterator, RecursiveCachingIterator and RecursiveRegexIterator generic in stubs - #6165

Merged
ondrejmirtes merged 5 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-c55mtbi
Jul 31, 2026
Merged

Make ReflectionObject, RecursiveFilterIterator, ParentIterator, RecursiveCachingIterator and RecursiveRegexIterator generic in stubs#6165
ondrejmirtes merged 5 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-c55mtbi

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

ReflectionObject had no stub of its own, so PHPStan saw it as a plain, non-generic subclass of the generic ReflectionClass<T>. The template argument was therefore erased to its bound, and every inherited method that returns T (newInstance(), newInstanceArgs(), newInstanceWithoutConstructor(), getName(), the $name property, …) degraded to object / class-string<object> — producing the reported false positive:

Function createInstance() should return T of object but returns object.

The fix adds a generic ReflectionObject stub, plus the same treatment for the SPL recursive iterators that sit on the identical axis.

Changes

  • New stubs/ReflectionObject.stub@template-covariant T of object, @extends ReflectionClass<T>, and a redeclared constructor with @param T $argument so new ReflectionObject($object) infers T from the argument.
  • New stubs/ReflectionObjectWithLazyObjects.stub — the same declaration with an invariant @template T of object, matching the invariant ReflectionClassWithLazyObjects.stub used on PHP >= 8.4 (the lazy-object methods take T in parameter position). This mirrors the existing ReflectionEnum.stub / ReflectionEnumWithLazyObjects.stub split.
  • src/PhpDoc/ReflectionClassStubFilesExtension.php — returns the matching ReflectionObject stub alongside the matching ReflectionClass stub in both branches.
  • stubs/iterable.stub — added generic declarations for the recursive iterators that were missing them:
    • RecursiveFilterIterator@extends FilterIterator<TKey, TValue, TIterator>, @implements RecursiveIterator<TKey, TValue>
    • ParentIterator@extends RecursiveFilterIterator<TKey, TValue, TIterator>
    • RecursiveCachingIterator@extends CachingIterator<TKey, TValue, TIterator>, @implements RecursiveIterator<TKey, TValue>
    • RecursiveRegexIterator@extends RegexIterator<TKey, TValue, TIterator>, @implements RecursiveIterator<TKey, TValue>
  • tests/PHPStan/Rules/Methods/OverridingMethodRuleTest.php — expectation for testBug9615 updated to the now-more-precise declaring class FilterIterator<mixed,mixed,RecursiveIterator<mixed, mixed>>.

Probed and found already correct (no change needed):

  • ReflectionEnum — already declares @template-covariant T of UnitEnum / @extends ReflectionClass<T> in both stub variants.
  • ReflectionClass::getAttributes() — already returns list<ReflectionAttribute<Foo>> when given Foo::class, via a dynamic return type extension.
  • SplStack, SplQueue, SplMinHeap, SplMaxHeap, RecursiveArrayIterator, RecursiveCallbackFilterIterator — already generic via stubs/spl.stub, stubs/iterable.stub or phpstorm-stubs.
  • RecursiveTreeIterator, DirectoryIterator, FilesystemIterator, SplFileObject, SplTempFileObject, GlobIterator — non-generic on purpose; they implement their iterator interfaces with concrete type arguments.

Root cause

The pattern is: a PHP built-in class extends a generic stubbed class but is not itself declared generic with an @extends tag, so PHPStan resolves the ancestor with the template types erased to their bounds.

To enumerate every instance, all internal classes and interfaces were reflected and fed through PHPStan's missingType.generics check to build the set of classes PHPStan considers generic; every non-generic internal class with a generic ancestor was then inspected. Affected locations:

  • ReflectionObject extends ReflectionClass<T>T erased to object (the reported bug).
  • RecursiveFilterIterator extends FilterIterator<TKey, TValue, TIterator>TKey/TValue erased to mixed.
  • ParentIterator extends RecursiveFilterIterator — same, transitively.
  • RecursiveCachingIterator extends CachingIterator<TKey, TValue, TIterator> — same.
  • RecursiveRegexIterator extends RegexIterator<TKey, TValue, TIterator> — same.

In each case the fix is the same: declare the subclass generic in the stub and thread the type parameters through @extends (and @implements RecursiveIterator<TKey, TValue> where the subclass adds recursion), redeclaring the constructor's PHPDoc where it takes a value the type parameters can be inferred from.

Test

  • tests/PHPStan/Analyser/nsrt/bug-15032.php — the reproducer from the issue's playground link. Asserts ReflectionObject<T of object> for new ReflectionObject($object) inside a @template T of object function and T for newInstance(), plus a concrete-class variant asserting ReflectionObject<Exception>, class-string<Exception> for getName() / $name, and Exception for newInstance(), newInstanceArgs() and newInstanceWithoutConstructor(). Fails before the fix with object / class-string<object> / ReflectionObject.
  • tests/PHPStan/Analyser/nsrt/recursive-iterators.php — covers the analogous cases: foreach key/value types, getChildren(), getInnerIterator() and current()/key() on ParentIterator, RecursiveCachingIterator and RecursiveRegexIterator, including template inference from the constructor argument (new RecursiveCachingIterator($recursiveArrayIterator)RecursiveCachingIterator<int, string, RecursiveArrayIterator<int, string>>). Fails before the fix with mixed and bare class names.

Both stub variants were verified by running the reproducer with phpVersion pinned to 7.4, 8.0 and 8.4, covering the non-lazy and lazy-objects ReflectionClass stubs. make tests and make phpstan are green.

Fixes phpstan/phpstan#15032

… `RecursiveCachingIterator` and `RecursiveRegexIterator` generic in stubs

* Add `stubs/ReflectionObject.stub` and `stubs/ReflectionObjectWithLazyObjects.stub` declaring `ReflectionObject` as `@extends ReflectionClass<T>` with `@param T $argument` on the constructor, mirroring the covariant/invariant split of the two `ReflectionClass` stub variants.
* Register both files in `ReflectionClassStubFilesExtension` next to the matching `ReflectionClass` stub, so the lazy-objects (PHP >= 8.4) and non-lazy variants stay in sync.
* Probed the sibling subclass of `ReflectionClass`: `ReflectionEnum` already declares `@extends ReflectionClass<T>` and needed no change.
* Swept the same "builtin subclass of a generic stubbed class has no `@extends`, so the type arguments are erased" pattern in `stubs/iterable.stub`: added generic declarations for `RecursiveFilterIterator`, `ParentIterator`, `RecursiveCachingIterator` and `RecursiveRegexIterator`, the recursive counterparts of the already generic `FilterIterator`, `CachingIterator` and `RegexIterator`.
* `OverridingMethodRuleTest::testBug9615` expectation updated: the declaring class of `FilterIterator::accept()` seen through `RecursiveFilterIterator` is now `FilterIterator<mixed,mixed,RecursiveIterator<mixed, mixed>>` instead of the erased `Traversable<mixed, mixed>` bound.

@ondrejmirtes ondrejmirtes left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

When a built-in PHP class becomes generic, it needs to be added to skipCheckGenericClasses. So that missingType.generics for this class is only reported with bleeding edge on.

Feel free to:

  1. Introduce a new attribute #[\PHPStan\PhpDoc\RequireGenericsInBleedingEdgeOnly]
  2. Configure composer attribute collector to scan stubs/ too
  3. Add a new ContainerExtension that will look for these attributes in stubs and append the skipCheckGenericClasses attribute
  4. Remove DOMNamedNodeMap from skipCheckGenericClasses and add the new attribute above it
  5. Mark all the classes made generic in this PR with the same attribute

phpstan-bot and others added 2 commits July 31, 2026 16:20
…] in stubs

A class that only becomes generic in a minor version cannot start requiring
type arguments right away - missingType.generics would fire in code that used
to be fine - so it is listed in featureToggles.skipCheckGenericClasses and the
check is deferred to bleeding edge. Listing the class name in conf/config.neon
keeps that decision far away from the stub that makes the class generic in the
first place; the attribute puts the two next to each other.

The classes are not collected by composer-attribute-collector like the rest of
PHPStan's attributes: the classes live in .stub files, which the class map
generator does not look at (it scans .php and .inc), and the collector reads a
class' attributes through runtime reflection, which for an internal class such
as ReflectionObject reports the attributes of the real PHP class and never the
ones written in a stub. SkipCheckGenericClassesExtension reads the stub files
itself instead, parsing only those that mention the attribute at all, and
registers them as container dependencies so that editing a stub rebuilds the
compiled container.

DOMNamedNodeMap, so far the only entry of the parameter, is marked with the
attribute in stubs/dom.stub instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…geOnly]

ReflectionObject, RecursiveFilterIterator, ParentIterator,
RecursiveCachingIterator and RecursiveRegexIterator all gained type parameters
in the previous commit's parent. Code that already mentions them without type
arguments was written when they had none, so requiring the arguments now would
report missingType.generics on code that could not have been written any other
way. The check is deferred to bleeding edge, like it is for DOMNamedNodeMap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Done — pushed as two commits.

What changed

1. #[\PHPStan\PhpDoc\RequireGenericsInBleedingEdgeOnly] + SkipCheckGenericClassesExtension (8e25fcdb2)

  • New attribute in src/PhpDoc/RequireGenericsInBleedingEdgeOnly.php.
  • New #[ContainerExtension(name: 'skipCheckGenericClasses')] in src/DependencyInjection/SkipCheckGenericClassesExtension.php that appends the marked classes to featureToggles.skipCheckGenericClasses — only when featureToggles.bleedingEdge is off, so bleeding edge still reports missingType.generics. It also registers the stub files via Compiler::addDependencies(), so editing a stub rebuilds the compiled container (without this the parameter went stale — I hit it while testing).
  • DOMNamedNodeMap removed from conf/config.neon and marked with the attribute in stubs/dom.stub.
  • Two container tests: one asserts the parameter contents by default, one asserts it is empty with bleedingEdge.neon.

2. Marked this PR's newly generic classes (799c65644) — ReflectionObject (both stub variants), RecursiveFilterIterator, ParentIterator, RecursiveCachingIterator, RecursiveRegexIterator.

Deviation from step 2/3 of the review

I did not configure composer-attribute-collector to scan stubs/, because it cannot collect these attributes. I verified both blockers empirically:

  • Composer's ClassMapGenerator is constructed by MemoizeClassMapGenerator with the default extensions ['php', 'inc'], so .stub files never enter the class map.
  • Even when a class in stubs/ is in the class map, ClassAttributeCollector::collectAttributes() on PHP >= 8 reads attributes via new ReflectionClass($class). For a stub class that isn't loadable it logs Attribute collection failed for ZzTestClass: Class "ZzTestClass" does not exist (I confirmed this with a throwaway stubs/ZzTest.php); for an internal class like ReflectionObject it succeeds but returns the real PHP class's attributes — never the ones written in the stub. The parser-based fallback in that class is gated behind PHP_VERSION_ID < 80000.

So the extension reads the stub files directly instead. It's cheap: it only parses files whose contents contain the attribute name (currently 3 of 40), and only at container-compile time. If you'd rather have this in the collector, it needs a release of ondrejmirtes/composer-attribute-collector that (a) accepts extra file extensions and (b) falls back to the AST collector when reflection can't see the class — happy to switch to that once it exists.

Verification

make phpstan, make tests (17778 tests), make cs and make lint are all green. I also confirmed SkipCheckGenericClassesExtensionTest fails without the source change (parameter comes back as just ['DOMNamedNodeMap']).

@ondrejmirtes ondrejmirtes left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I really would like to have this be statically found out by composer attribute collector. It doesn't use runtime reflection, it uses nikic/PHP-Parser so it should be possible. If the problem is the .stub extension, send a PR to https://github.com/ondrejmirtes/simple-downgrader/ that makes it configurable.

…ibutes

Names the two things missing in the collector - the class map only knows .php
and .inc files, and attributes are read through runtime reflection on PHP >= 8,
which for an internal class such as ReflectionObject describes the real class
instead of the stub - so that this extension can be replaced with a call to
Attributes::findTargetClasses() once a release supporting both is out.
@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

All background pollers are cleared; the working tree is clean and the branch is pushed at 57f6c3d8f.

Final state:

  • Collector change (the thing the review asked for): implemented and tested in a local clone of ondrejmirtes/composer-attribute-collector — configurable extensions + AST-based collection when reflection can't describe the scanned file. Its own suite, phpstan and phpcs are green, and both new tests fail without the source change. Diff is in my previous message; I couldn't open the PR because the bot token is repo-scoped (403 on fork and repo creation).
  • phpstan-src: unchanged behaviour this round — one commit updating SkipCheckGenericClassesExtension's doc to name the two upstream gaps and the removal path. make tests 17778 tests / 0 failures, make phpstan [OK] No errors.
  • Not landed, with reason stated: the switch to Attributes::findTargetClasses() needs a collector release; the patches/ shortcut breaks fresh composer install (exit 1 on the first run) so I reverted it rather than shipping it.

@ondrejmirtes ondrejmirtes left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Alright never mind, remove the atgribute machinery and just leave the stubs and make sure the skipping is in the config

Reverts the #[RequireGenericsInBleedingEdgeOnly] attribute and the container
extension reading it out of the stub files, as requested in review. The five
classes that became generic in this PR are named in conf/config.neon next to
DOMNamedNodeMap instead, so missingType.generics for them is only reported
with bleeding edge enabled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Done — pushed as 089ed2d82.

What changed

The attribute machinery is gone; the PR is back to stubs + a config entry.

Removed:

  • src/PhpDoc/RequireGenericsInBleedingEdgeOnly.php
  • src/DependencyInjection/SkipCheckGenericClassesExtension.php
  • Both container tests (SkipCheckGenericClassesExtensionTest, SkipCheckGenericClassesBleedingEdgeExtensionTest)
  • The #[\PHPStan\PhpDoc\RequireGenericsInBleedingEdgeOnly] lines from stubs/ReflectionObject.stub, stubs/ReflectionObjectWithLazyObjects.stub, stubs/dom.stub and stubs/iterable.stubdom.stub is byte-identical to its pre-PR state again.

Config (conf/config.neon) now lists the newly generic classes alongside the restored DOMNamedNodeMap:

skipCheckGenericClasses:
    - DOMNamedNodeMap
    - ParentIterator
    - RecursiveCachingIterator
    - RecursiveFilterIterator
    - RecursiveRegexIterator
    - ReflectionObject

The net diff versus b51f024f2 (the stubs-only commit) is exactly those five added lines.

Verification of the skipping

Analysed a file taking each of the six classes as a bare parameter type, plus a class extending RecursiveFilterIterator:

  • Default config → [OK] No errors
  • With conf/bleedingEdge.neon included → 6 missingType.generics errors, one per class, including the extends-side check via GenericAncestorsCheck

make phpstan [OK] No errors, make tests 17776 tests / 0 failures (97 skipped), make cs and make lint green. I also re-ran composer dump-autoload so the generated vendor/attributes.php no longer references the deleted container extension.

@ondrejmirtes
ondrejmirtes enabled auto-merge (squash) July 31, 2026 18:13
@ondrejmirtes
ondrejmirtes merged commit 19151d3 into phpstan:2.2.x Jul 31, 2026
738 of 744 checks passed
@ondrejmirtes
ondrejmirtes deleted the create-pull-request/patch-c55mtbi branch July 31, 2026 18:13
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.

Unexpected error when using ReflectionClass::newInstance()

2 participants