Skip to content

Collect template argument constraints through object shape properties in TemplateArgumentObserver - #6817

Open
phpstan-bot wants to merge 1 commit into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-sub5gw1
Open

phpstan-bot wants to merge 1 commit into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-sub5gw1

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

When a function body's new Collection(...) sits inside an object shape ((object) ['x' => new Collection($items)]), the declared type it flows into (@return Collection<int, object{x: Collection<int, object{t: string}>}>) did not widen the inner collection's invariant template argument. The exact inferred key type (0|1|2|3) stayed, and PHPStan reported a return.type error. The same code with an array shape (['x' => new Collection(...)]) already worked.

This PR makes the template-argument observer follow object shape properties the way it already follows array shape offsets and generic class arguments.

Changes

  • src/Analyser/Generics/TemplateArgumentObserver.php
    • observeSend(): when the declared type is an ObjectShapeType, delegate to the new observeObjectShapeSend(). That method sends each declared property type against the readable type of the actual object's matching public, non-static property.
    • When the declared type is an intersection (object{...}&\stdClass), send to each object shape member as well, then continue with the existing class-based matching.
  • turbo-ext/src/TemplateArgumentObserver.cpp: the same logic in the native mirror. turbo-ext/src/generated/TemplateArgumentObserver.h is regenerated for the new private method.
  • phpstan-baseline.neon: baseline entries for the two deliberate instanceof ObjectShapeType checks and the one instanceof IntersectionType check. The Type interface has no way to list an object shape's properties.

Analogous flows checked. All of them were broken before and are fixed now, each with a test:

  • return type (plain, nested shapes, |null, &\stdClass intersection, optional shape properties inside list<>)
  • typed property assignment
  • call argument
  • yield against the Generator value type

Probed and not changed: a closure returned as Closure(): object{...} still does not constrain its body's new sites. This also happens with Closure(): Collection<int, string> and Closure(): array{...}, so it is a separate limitation unrelated to object shapes.

Root cause

observeSend() collects constraints for unresolved template arguments by walking the declared and actual types together: union members, generic class arguments (through getObjectClassReflections()), and iterable key/value types. An object shape fits none of these. The actual object{x: Collection<…>}&stdClass has one class reflection (stdClass), but the declared object{x: Collection<int, string>} has none, so the walk returned early. The new Collection(...) site inside was never constrained and kept its exact inferred TKey.

Test

  • tests/PHPStan/Rules/Methods/data/bug-15417.php (ReturnTypeRuleTest::testBug15417, PHP >= 8.2): the reproducer from the issue, verbatim.
  • tests/PHPStan/Rules/Methods/data/bug-15417-shapes.php (ReturnTypeRuleTest::testBug15417Shapes): nested, nullable, intersection and optional-property return cases, plus a case that is still reported when the declared argument really is incompatible (Collection<string, string>).
  • tests/PHPStan/Rules/Methods/data/bug-15417-argument.php (CallStaticMethodsRuleTest::testBug15417): call argument.
  • tests/PHPStan/Rules/Properties/data/bug-15417.php (TypesAssignedToPropertiesRuleTest::testBug15417): property assignment.
  • tests/PHPStan/Rules/Generators/data/bug-15417.php (YieldTypeRuleTest::testBug15417): yield.

All of these fail without the fix and pass with it. The full suite passes both with and without the turbo extension loaded, and the turbo smoke test and method parity check pass.

Fixes phpstan/phpstan#15417

🤖 Generated with Claude Code

… in `TemplateArgumentObserver`

- TemplateArgumentObserver::observeSend() now matches a declared `object{...}`
  shape property by property against the actual object's public non-static
  properties (new observeObjectShapeSend()). Before, a declared shape names no
  class, so `(object) ['x' => new Collection([...])]` returned or passed as
  `object{x: Collection<int, string>}` never constrained the inner `new`
  site, and its invariant TKey stayed at the exact `0|1|...` literal.
- A declared intersection `object{...}&Foo` sends to each shape member too.
- Ported the same logic to the native mirror in
  turbo-ext/src/TemplateArgumentObserver.cpp and regenerated its declarations
  header.
- Covers every place that sends to a declared type: return, typed property
  assignment, call argument and `yield` (Generator value type), plus nested
  shapes, nullable unions and optional shape properties.
- Probed and left alone: closures returned as `Closure(): object{...}` do not
  constrain their body's `new` sites even with plain generic or array shape
  return types. That limitation is separate from object shapes.
@ondrejmirtes
ondrejmirtes force-pushed the create-pull-request/patch-sub5gw1 branch from 99453bb to b23037a Compare October 27, 2026 18:45
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.

Generic not widening based on return type

1 participant