From d39d7e8c6dbf36a486b34e0640b5f92d81546835 Mon Sep 17 00:00:00 2001 From: Eric Stern Date: Mon, 4 May 2026 08:32:51 -0700 Subject: [PATCH 1/8] Repro test --- .../DoctrineIntegration/TypeInferenceTest.php | 1 + .../data/repositoryMatching.php | 39 +++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 tests/DoctrineIntegration/data/repositoryMatching.php diff --git a/tests/DoctrineIntegration/TypeInferenceTest.php b/tests/DoctrineIntegration/TypeInferenceTest.php index 0382118b..634676f3 100644 --- a/tests/DoctrineIntegration/TypeInferenceTest.php +++ b/tests/DoctrineIntegration/TypeInferenceTest.php @@ -12,6 +12,7 @@ public function dataFileAsserts(): iterable yield from $this->gatherAssertTypes(__DIR__ . '/data/getRepository.php'); yield from $this->gatherAssertTypes(__DIR__ . '/data/isEmpty.php'); yield from $this->gatherAssertTypes(__DIR__ . '/data/Collection.php'); + yield from $this->gatherAssertTypes(__DIR__ . '/data/repositoryMatching.php'); } /** diff --git a/tests/DoctrineIntegration/data/repositoryMatching.php b/tests/DoctrineIntegration/data/repositoryMatching.php new file mode 100644 index 00000000..28982fde --- /dev/null +++ b/tests/DoctrineIntegration/data/repositoryMatching.php @@ -0,0 +1,39 @@ +entityManager->getRepository(MyEntity::class); + $criteria = Criteria::create(); + $result = $repository->matching($criteria); + assertType('Doctrine\Common\Collections\AbstractLazyCollection&Doctrine\Common\Collections\Selectable', $result); + } + + /** + * @param EntityRepository $repository + */ + public function withTypedRepository(EntityRepository $repository): void + { + $criteria = Criteria::create(); + $result = $repository->matching($criteria); + assertType('Doctrine\Common\Collections\AbstractLazyCollection&Doctrine\Common\Collections\Selectable', $result); + } + +} + +class MyEntity +{ + +} From 344cccd46aaf6177ba8a26d59a290f365a822113 Mon Sep 17 00:00:00 2001 From: Eric Stern Date: Mon, 4 May 2026 10:20:49 -0700 Subject: [PATCH 2/8] &Selectable is redundant, remove it --- stubs/EntityRepository.stub | 4 +--- tests/DoctrineIntegration/data/repositoryMatching.php | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/stubs/EntityRepository.stub b/stubs/EntityRepository.stub index 77b5b337..0b9952d5 100644 --- a/stubs/EntityRepository.stub +++ b/stubs/EntityRepository.stub @@ -61,9 +61,7 @@ class EntityRepository implements ObjectRepository /** * @param \Doctrine\Common\Collections\Criteria $criteria * - * @return \Doctrine\Common\Collections\Collection - * - * @psalm-return \Doctrine\Common\Collections\Collection + * @phpstan-return \Doctrine\Common\Collections\AbstractLazyCollection */ public function matching(Criteria $criteria); diff --git a/tests/DoctrineIntegration/data/repositoryMatching.php b/tests/DoctrineIntegration/data/repositoryMatching.php index 28982fde..84a6f481 100644 --- a/tests/DoctrineIntegration/data/repositoryMatching.php +++ b/tests/DoctrineIntegration/data/repositoryMatching.php @@ -18,7 +18,7 @@ public function doFoo(): void $repository = $this->entityManager->getRepository(MyEntity::class); $criteria = Criteria::create(); $result = $repository->matching($criteria); - assertType('Doctrine\Common\Collections\AbstractLazyCollection&Doctrine\Common\Collections\Selectable', $result); + assertType('Doctrine\Common\Collections\AbstractLazyCollection', $result); } /** @@ -28,7 +28,7 @@ public function withTypedRepository(EntityRepository $repository): void { $criteria = Criteria::create(); $result = $repository->matching($criteria); - assertType('Doctrine\Common\Collections\AbstractLazyCollection&Doctrine\Common\Collections\Selectable', $result); + assertType('Doctrine\Common\Collections\AbstractLazyCollection', $result); } } From 6dc740a06b036d6a30a1402ac24781114a35404e Mon Sep 17 00:00:00 2001 From: Eric Stern Date: Mon, 4 May 2026 11:14:12 -0700 Subject: [PATCH 3/8] Correct EMI stub to match upstream definition --- stubs/EntityManagerInterface.stub | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/stubs/EntityManagerInterface.stub b/stubs/EntityManagerInterface.stub index 5fd8024a..08ad2717 100644 --- a/stubs/EntityManagerInterface.stub +++ b/stubs/EntityManagerInterface.stub @@ -2,8 +2,8 @@ namespace Doctrine\ORM; +use Doctrine\Persistence\EntityRepository; use Doctrine\Persistence\ObjectManager; -use Doctrine\Persistence\ObjectRepository; use Doctrine\ORM\Exception\ORMException; use Doctrine\ORM\Mapping\ClassMetadata; @@ -29,7 +29,7 @@ interface EntityManagerInterface extends ObjectManager /** * @template T of object * @phpstan-param class-string $className - * @phpstan-return ObjectRepository + * @phpstan-return EntityRepository */ public function getRepository($className); From 42afa9c0f27c7dee461b3cf6278f3a277426b414 Mon Sep 17 00:00:00 2001 From: Eric Stern Date: Mon, 4 May 2026 11:19:11 -0700 Subject: [PATCH 4/8] Include union, add assertions when looping over data too --- stubs/EntityRepository.stub | 2 +- .../data/repositoryMatching.php | 14 ++++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/stubs/EntityRepository.stub b/stubs/EntityRepository.stub index 0b9952d5..a8ca60e1 100644 --- a/stubs/EntityRepository.stub +++ b/stubs/EntityRepository.stub @@ -61,7 +61,7 @@ class EntityRepository implements ObjectRepository /** * @param \Doctrine\Common\Collections\Criteria $criteria * - * @phpstan-return \Doctrine\Common\Collections\AbstractLazyCollection + * @phpstan-return \Doctrine\Common\Collections\AbstractLazyCollection&\Doctrine\Common\Collections\Selectable */ public function matching(Criteria $criteria); diff --git a/tests/DoctrineIntegration/data/repositoryMatching.php b/tests/DoctrineIntegration/data/repositoryMatching.php index 84a6f481..73efe3e3 100644 --- a/tests/DoctrineIntegration/data/repositoryMatching.php +++ b/tests/DoctrineIntegration/data/repositoryMatching.php @@ -17,8 +17,11 @@ public function doFoo(): void { $repository = $this->entityManager->getRepository(MyEntity::class); $criteria = Criteria::create(); - $result = $repository->matching($criteria); - assertType('Doctrine\Common\Collections\AbstractLazyCollection', $result); + $results = $repository->matching($criteria); + assertType('Doctrine\Common\Collections\AbstractLazyCollection', $results); + foreach ($results as $result) { + assertType(MyEntity::class, $result); + } } /** @@ -27,8 +30,11 @@ public function doFoo(): void public function withTypedRepository(EntityRepository $repository): void { $criteria = Criteria::create(); - $result = $repository->matching($criteria); - assertType('Doctrine\Common\Collections\AbstractLazyCollection', $result); + $results = $repository->matching($criteria); + assertType('Doctrine\Common\Collections\AbstractLazyCollection', $results); + foreach ($results as $result) { + assertType(MyEntity::class, $result); + } } } From 08ef2f37f21c0cfaa06b1d60977065bb811577d6 Mon Sep 17 00:00:00 2001 From: Eric Stern Date: Mon, 4 May 2026 11:27:40 -0700 Subject: [PATCH 5/8] Correct the types --- stubs/EntityManagerInterface.stub | 4 ++-- stubs/EntityRepository.stub | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/stubs/EntityManagerInterface.stub b/stubs/EntityManagerInterface.stub index 08ad2717..c2b3bcd3 100644 --- a/stubs/EntityManagerInterface.stub +++ b/stubs/EntityManagerInterface.stub @@ -2,10 +2,10 @@ namespace Doctrine\ORM; -use Doctrine\Persistence\EntityRepository; -use Doctrine\Persistence\ObjectManager; +use Doctrine\ORM\EntityRepository; use Doctrine\ORM\Exception\ORMException; use Doctrine\ORM\Mapping\ClassMetadata; +use Doctrine\Persistence\ObjectManager; interface EntityManagerInterface extends ObjectManager { diff --git a/stubs/EntityRepository.stub b/stubs/EntityRepository.stub index a8ca60e1..4b97676d 100644 --- a/stubs/EntityRepository.stub +++ b/stubs/EntityRepository.stub @@ -2,7 +2,9 @@ namespace Doctrine\ORM; +use Doctrine\Common\Collections\AbstractLazyCollection; use Doctrine\Common\Collections\Criteria; +use Doctrine\Common\Collections\Selectable; use Doctrine\Persistence\ObjectRepository; /** @@ -59,9 +61,9 @@ class EntityRepository implements ObjectRepository protected function getEntityName(); /** - * @param \Doctrine\Common\Collections\Criteria $criteria + * @param Criteria $criteria * - * @phpstan-return \Doctrine\Common\Collections\AbstractLazyCollection&\Doctrine\Common\Collections\Selectable + * @phpstan-return AbstractLazyCollection&Selectable */ public function matching(Criteria $criteria); From c08ff6f275473d99047855756e2276eca822bcec Mon Sep 17 00:00:00 2001 From: Eric Stern Date: Mon, 4 May 2026 12:07:27 -0700 Subject: [PATCH 6/8] Simplify to collection --- stubs/EntityRepository.stub | 4 ++-- tests/DoctrineIntegration/data/repositoryMatching.php | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/stubs/EntityRepository.stub b/stubs/EntityRepository.stub index 4b97676d..2fd3d8e3 100644 --- a/stubs/EntityRepository.stub +++ b/stubs/EntityRepository.stub @@ -2,7 +2,7 @@ namespace Doctrine\ORM; -use Doctrine\Common\Collections\AbstractLazyCollection; +use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Criteria; use Doctrine\Common\Collections\Selectable; use Doctrine\Persistence\ObjectRepository; @@ -63,7 +63,7 @@ class EntityRepository implements ObjectRepository /** * @param Criteria $criteria * - * @phpstan-return AbstractLazyCollection&Selectable + * @phpstan-return Collection&Selectable */ public function matching(Criteria $criteria); diff --git a/tests/DoctrineIntegration/data/repositoryMatching.php b/tests/DoctrineIntegration/data/repositoryMatching.php index 73efe3e3..8fe4ec11 100644 --- a/tests/DoctrineIntegration/data/repositoryMatching.php +++ b/tests/DoctrineIntegration/data/repositoryMatching.php @@ -18,7 +18,7 @@ public function doFoo(): void $repository = $this->entityManager->getRepository(MyEntity::class); $criteria = Criteria::create(); $results = $repository->matching($criteria); - assertType('Doctrine\Common\Collections\AbstractLazyCollection', $results); + assertType('Doctrine\Common\Collections\Collection&Doctrine\Common\Collections\Selectable', $results); foreach ($results as $result) { assertType(MyEntity::class, $result); } @@ -31,7 +31,7 @@ public function withTypedRepository(EntityRepository $repository): void { $criteria = Criteria::create(); $results = $repository->matching($criteria); - assertType('Doctrine\Common\Collections\AbstractLazyCollection', $results); + assertType('Doctrine\Common\Collections\Collection&Doctrine\Common\Collections\Selectable', $results); foreach ($results as $result) { assertType(MyEntity::class, $result); } From 356ce07396eb6615ab472bf0898c61f2c88c3947 Mon Sep 17 00:00:00 2001 From: Eric Stern Date: Mon, 4 May 2026 13:06:46 -0700 Subject: [PATCH 7/8] Use dynamic return type extension for EntityRepository::matching() Fixes generic type inference for both ORM2 and ORM3 by computing the return type programmatically instead of relying on stubs. Co-Authored-By: Claude Opus 4.5 --- extension.neon | 4 ++ ...toryMatchingDynamicReturnTypeExtension.php | 43 +++++++++++++++++++ stubs/EntityRepository.stub | 9 ---- 3 files changed, 47 insertions(+), 9 deletions(-) create mode 100644 src/Type/Doctrine/EntityRepositoryMatchingDynamicReturnTypeExtension.php diff --git a/extension.neon b/extension.neon index 18bf1904..252da0fd 100644 --- a/extension.neon +++ b/extension.neon @@ -114,6 +114,10 @@ services: class: PHPStan\Type\Doctrine\DoctrineSelectableDynamicReturnTypeExtension tags: - phpstan.broker.dynamicMethodReturnTypeExtension + - + class: PHPStan\Type\Doctrine\EntityRepositoryMatchingDynamicReturnTypeExtension + tags: + - phpstan.broker.dynamicMethodReturnTypeExtension - class: PHPStan\Type\Doctrine\ObjectMetadataResolver arguments: diff --git a/src/Type/Doctrine/EntityRepositoryMatchingDynamicReturnTypeExtension.php b/src/Type/Doctrine/EntityRepositoryMatchingDynamicReturnTypeExtension.php new file mode 100644 index 00000000..f5027d8d --- /dev/null +++ b/src/Type/Doctrine/EntityRepositoryMatchingDynamicReturnTypeExtension.php @@ -0,0 +1,43 @@ +getName() === 'matching'; + } + + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope + ): Type + { + $callerType = $scope->getType($methodCall->var); + $entityType = $callerType->getTemplateType(EntityRepository::class, 'TEntityClass'); + + return new IntersectionType([ + new GenericObjectType('Doctrine\Common\Collections\Collection', [new IntegerType(), $entityType]), + new GenericObjectType('Doctrine\Common\Collections\Selectable', [new IntegerType(), $entityType]), + ]); + } + +} diff --git a/stubs/EntityRepository.stub b/stubs/EntityRepository.stub index 2fd3d8e3..8ef22d02 100644 --- a/stubs/EntityRepository.stub +++ b/stubs/EntityRepository.stub @@ -2,9 +2,7 @@ namespace Doctrine\ORM; -use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Criteria; -use Doctrine\Common\Collections\Selectable; use Doctrine\Persistence\ObjectRepository; /** @@ -60,13 +58,6 @@ class EntityRepository implements ObjectRepository */ protected function getEntityName(); - /** - * @param Criteria $criteria - * - * @phpstan-return Collection&Selectable - */ - public function matching(Criteria $criteria); - /** * @param __doctrine-literal-string $alias * @param __doctrine-literal-string|null $indexBy From 0ac3a226d709a7f91094cbdf5b8658fd503ce277 Mon Sep 17 00:00:00 2001 From: Eric Stern Date: Mon, 4 May 2026 13:12:15 -0700 Subject: [PATCH 8/8] sort --- .../EntityRepositoryMatchingDynamicReturnTypeExtension.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Type/Doctrine/EntityRepositoryMatchingDynamicReturnTypeExtension.php b/src/Type/Doctrine/EntityRepositoryMatchingDynamicReturnTypeExtension.php index f5027d8d..0f93e9be 100644 --- a/src/Type/Doctrine/EntityRepositoryMatchingDynamicReturnTypeExtension.php +++ b/src/Type/Doctrine/EntityRepositoryMatchingDynamicReturnTypeExtension.php @@ -8,8 +8,8 @@ use PHPStan\Reflection\MethodReflection; use PHPStan\Type\DynamicMethodReturnTypeExtension; use PHPStan\Type\Generic\GenericObjectType; -use PHPStan\Type\IntersectionType; use PHPStan\Type\IntegerType; +use PHPStan\Type\IntersectionType; use PHPStan\Type\Type; class EntityRepositoryMatchingDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension