Fix collection method chaining type analysis - #47
Draft
joetannenbaum wants to merge 8 commits into
Draft
Conversation
- Support @template-covariant tags in parseTemplateTags() so Collection<TValue> is correctly recognized (TValue was previously invisible) - Add getAllTemplateTagNames() combining @template and @template-covariant - Add parseExtendsTags() to parse @extends Foo<T> docblock tags - Add resolveExtendsChain() in Reflector to map parent class template names through @extends, enabling EloquentCollection<TModel> → Collection<TValue> inheritance to propagate generic types correctly - Add bindMethodLevelTemplateTags() so method-level @template params (e.g. TFirstDefault in first()) are properly handled and do not resolve as strings - Add generalizeLiteralType() to normalize string/int/float literals to their base types when building collection item types - Add resolveUseTraitBindings() and parseTraitUseBindings() to inject @use Trait<Type> bindings into scope (e.g. BuildsQueries<TModel> on Builder) - Fix static/self resolution in IdentifierTypeNode to clone the receiver type so method chaining returns the correct concrete type - Add CollectionChainingTest with 9 integration tests covering regular and Eloquent collection method chains Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add getCallableParamReturnTemplates() to DocBlockParser: scans @PARAM tags at the PHPStan AST level to find callable params whose return type is an identifier (e.g. callable(TValue, TKey): TMapValue), returning [paramName => templateName] without triggering type resolution - Add bindCallableArgTemplates() to Reflector: after binding method-level templates with NullType defaults, replaces them with the actual return type of the closure/arrow function passed at the corresponding argument position - Add resolveCallableArgReturnTypes() to ResolvesMethodCalls: iterates over the method call's arg nodes, resolves closures and arrow functions via resolveClosureReturnType(), and passes the result map to methodReturnType() as a new $closureReturnTypes parameter - Use ResolvesClosureReturnTypes trait in ResolvesMethodCalls Result: collect(['a','b','c'])->map(fn($x) => strlen($x)) now resolves to Collection<int, int> and ->map(fn($x) => strtoupper($x)) to Collection<int, string> instead of Collection<int, null>. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Support @template-covariant, @extends chain following, method-level template binding, resolveUseTraitBindings, and static/self clone fixes - Flow TValue/TKey into untyped closure params in map() and similar methods via resolveClosuresWithParamHints() - Fix Eloquent map() returning UnionType by extending ClassType::isMoreSpecificThan to check is_a() subtype relationships - Fix scope corruption: save/restore Reflector scope around closureResolver callback (AbstractResolver::setScope also sets reflector scope, so the callback was wiping the tempScope with template-tag bindings) - Add getCallableParamInputTypeNames() to DocBlockParser - Add resolveClosureReturnTypeWithParamHints() to ResolvesClosureReturnTypes - Add 2 new tests: TValue flow into closure params + Eloquent map not UnionType - All 244 tests pass (2 new) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
joetannenbaum
marked this pull request as draft
May 20, 2026 01:46
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.
Summary
Fixes type analysis for method chaining on
Illuminate\Support\CollectionandIlluminate\Database\Eloquent\Collection— e.g.collect(['a', 'b'])->filter()->first()now correctly resolves tostring|nullinstead ofmixed, andcollect(['a','b','c'])->map(fn($x) => strlen($x))resolves toCollection<int, int>.Root causes fixed
@template-covariantwas invisibleCollectionuses@template-covariant TValuebutPhpDocNode::getTemplateTagValues()only returns@templatetags. Added support for@template-covariantinparseTemplateTags().@extendschain not followed for inherited methodsEloquentCollection<TModel>extendsCollection<TKey, TModel>, mappingTValue → TModel. Without following this chain, methods defined onCollection(likefirst()) saw unresolvedTValuewhen called on anEloquentCollection. AddedparseExtendsTags()andresolveExtendsChain()to propagate these mappings.Method-level
@templateparams resolved as string literalsfirst()declares@template TFirstDefaultat the method level. Without handling this,TFirstDefaultresolved toStringType('TFirstDefault')instead of being treated as an unbound template. AddedbindMethodLevelTemplateTags()to inject method-level template params into scope.map()TMapValue not inferred from closure argumentmap()declares@template TMapValuebound viacallable(TValue, TKey): TMapValue. The return typeTMapValuewas defaulting tonull. Added:getCallableParamReturnTemplates()inDocBlockParser— scans@paramtags at AST level to find callable params whose return type is an identifier, returning[paramName => templateName]bindCallableArgTemplates()inReflector— resolves the closure/arrow function passed at the matching argument position and binds the template to its return typeresolveCallableArgReturnTypes()inResolvesMethodCalls— pre-resolves closure/arrow function arguments before passing tomethodReturnType()@use Trait<Type>bindings not resolvedBuilderusesBuildsQueries<TModel>via a@usedocblock on the trait use statement. AddedresolveUseTraitBindings()andparseTraitUseBindings()to parse these bindings and inject them into the scope.Literal types not generalized in
collect()collect(['a', 'b', 'c'])was building a type from literalStringType('a')values rather than the generalizedStringType. AddedgeneralizeLiteralType()to normalize these.static/selfreturned wrong type after chainingWhen a method returns
static/self, the receiver type (the concrete class with generic bindings) should be cloned — not re-derived from the entity name. Fixed inIdentifierTypeNode::resolve().Tests
Added
tests/Unit/CollectionChainingTest.phpwith 11 integration tests:collect(['a','b','c'])->first()→string|nullcollect(['a','b','c'])->filter()->first()→string|nullcollect(['a','b','c'])->filter()→Collection<int, string>collect(['a','b','c'])->map(fn($x) => strlen($x))→Collection<int, int>collect(['a','b','c'])->map(fn($x) => strtoupper($x))→Collection<int, string>User::all()->first()→User|nullUser::all()->filter()->first()→User|nullUser::all()->filter()→EloquentCollection<int, User>Post::query()->get()->first()→Post|nullPost::query()->get()->filter()->first()→Post|nullPost::query()->get()->filter()→EloquentCollection<int, Post>All 242 existing tests continue to pass (2 skipped).