Skip to content

Fix collection method chaining type analysis - #47

Draft
joetannenbaum wants to merge 8 commits into
mainfrom
joetannenbaum/fix-collection-method-chaining
Draft

Fix collection method chaining type analysis#47
joetannenbaum wants to merge 8 commits into
mainfrom
joetannenbaum/fix-collection-method-chaining

Conversation

@joetannenbaum

@joetannenbaum joetannenbaum commented May 19, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes type analysis for method chaining on Illuminate\Support\Collection and Illuminate\Database\Eloquent\Collection — e.g. collect(['a', 'b'])->filter()->first() now correctly resolves to string|null instead of mixed, and collect(['a','b','c'])->map(fn($x) => strlen($x)) resolves to Collection<int, int>.

Root causes fixed

@template-covariant was invisible
Collection uses @template-covariant TValue but PhpDocNode::getTemplateTagValues() only returns @template tags. Added support for @template-covariant in parseTemplateTags().

@extends chain not followed for inherited methods
EloquentCollection<TModel> extends Collection<TKey, TModel>, mapping TValue → TModel. Without following this chain, methods defined on Collection (like first()) saw unresolved TValue when called on an EloquentCollection. Added parseExtendsTags() and resolveExtendsChain() to propagate these mappings.

Method-level @template params resolved as string literals
first() declares @template TFirstDefault at the method level. Without handling this, TFirstDefault resolved to StringType('TFirstDefault') instead of being treated as an unbound template. Added bindMethodLevelTemplateTags() to inject method-level template params into scope.

map() TMapValue not inferred from closure argument
map() declares @template TMapValue bound via callable(TValue, TKey): TMapValue. The return type TMapValue was defaulting to null. Added:

  • getCallableParamReturnTemplates() in DocBlockParser — scans @param tags at AST level to find callable params whose return type is an identifier, returning [paramName => templateName]
  • bindCallableArgTemplates() in Reflector — resolves the closure/arrow function passed at the matching argument position and binds the template to its return type
  • resolveCallableArgReturnTypes() in ResolvesMethodCalls — pre-resolves closure/arrow function arguments before passing to methodReturnType()

@use Trait<Type> bindings not resolved
Builder uses BuildsQueries<TModel> via a @use docblock on the trait use statement. Added resolveUseTraitBindings() and parseTraitUseBindings() 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 literal StringType('a') values rather than the generalized StringType. Added generalizeLiteralType() to normalize these.

static/self returned wrong type after chaining
When 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 in IdentifierTypeNode::resolve().

Tests

Added tests/Unit/CollectionChainingTest.php with 11 integration tests:

  • collect(['a','b','c'])->first()string|null
  • collect(['a','b','c'])->filter()->first()string|null
  • collect(['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|null
  • User::all()->filter()->first()User|null
  • User::all()->filter()EloquentCollection<int, User>
  • Post::query()->get()->first()Post|null
  • Post::query()->get()->filter()->first()Post|null
  • Post::query()->get()->filter()EloquentCollection<int, Post>

All 242 existing tests continue to pass (2 skipped).

joetannenbaum and others added 8 commits May 19, 2026 09:33
- 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
joetannenbaum marked this pull request as draft May 20, 2026 01:46
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