From 889fc1f3eb30955c650d3cf142889be8c7bb896a Mon Sep 17 00:00:00 2001 From: Eric Stern Date: Mon, 4 May 2026 08:32:51 -0700 Subject: [PATCH 1/9] 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 7f81aaca2fd12183fc1109952c9d230cecf41a00 Mon Sep 17 00:00:00 2001 From: Eric Stern Date: Mon, 4 May 2026 10:20:49 -0700 Subject: [PATCH 2/9] &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 36f4d8430b6ed2253f13879580fe9c9fa379de74 Mon Sep 17 00:00:00 2001 From: Eric Stern Date: Mon, 4 May 2026 11:14:12 -0700 Subject: [PATCH 3/9] 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 4060f7ba16f48c317f77333c4f115928706714f1 Mon Sep 17 00:00:00 2001 From: Eric Stern Date: Mon, 4 May 2026 11:19:11 -0700 Subject: [PATCH 4/9] 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 671dae404d92cc57e263ac59fb9d7d6060eb3e2e Mon Sep 17 00:00:00 2001 From: Eric Stern Date: Mon, 4 May 2026 11:27:40 -0700 Subject: [PATCH 5/9] 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 cf3cbc484b082c7a231bbbb52658fcd937213d70 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Mon, 4 May 2026 23:16:51 +0200 Subject: [PATCH 6/9] Add stub --- extension.neon | 1 + stubs/Collections/AbstractLazyCollection.stub | 13 +++++++++++++ 2 files changed, 14 insertions(+) create mode 100644 stubs/Collections/AbstractLazyCollection.stub diff --git a/extension.neon b/extension.neon index 18bf1904..8e2bfcca 100644 --- a/extension.neon +++ b/extension.neon @@ -32,6 +32,7 @@ parameters: - stubs/Persistence/ObjectManagerDecorator.stub - stubs/Persistence/ObjectRepository.stub - stubs/RepositoryFactory.stub + - stubs/Collections/AbstractLazyCollection.stub - stubs/Collections/ArrayCollection.stub - stubs/Collections/Selectable.stub - stubs/ORM/AbstractQuery.stub diff --git a/stubs/Collections/AbstractLazyCollection.stub b/stubs/Collections/AbstractLazyCollection.stub new file mode 100644 index 00000000..bb00b3b0 --- /dev/null +++ b/stubs/Collections/AbstractLazyCollection.stub @@ -0,0 +1,13 @@ + + * @template-implements Selectable + */ +abstract class AbstractLazyCollection implements Collection, Selectable +{ +} From 9e07424e58bae31c2698c3eafc88368e95ad340c Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Mon, 4 May 2026 23:23:16 +0200 Subject: [PATCH 7/9] Update tests --- .../DoctrineIntegration/TypeInferenceTest.php | 10 ++++- .../data/repositoryMatching-collection25.php | 45 +++++++++++++++++++ ...hp => repositoryMatching-collection26.php} | 0 3 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 tests/DoctrineIntegration/data/repositoryMatching-collection25.php rename tests/DoctrineIntegration/data/{repositoryMatching.php => repositoryMatching-collection26.php} (100%) diff --git a/tests/DoctrineIntegration/TypeInferenceTest.php b/tests/DoctrineIntegration/TypeInferenceTest.php index 634676f3..fb8efda4 100644 --- a/tests/DoctrineIntegration/TypeInferenceTest.php +++ b/tests/DoctrineIntegration/TypeInferenceTest.php @@ -2,7 +2,10 @@ namespace PHPStan\DoctrineIntegration; +use Doctrine\Common\Collections\AbstractLazyCollection; +use Doctrine\Common\Collections\Selectable; use PHPStan\Testing\TypeInferenceTestCase; +use function is_a; class TypeInferenceTest extends TypeInferenceTestCase { @@ -12,7 +15,12 @@ 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'); + + if (is_a(AbstractLazyCollection::class, Selectable::class, true)) { // @phpstan-ignore function.alreadyNarrowedType + yield from $this->gatherAssertTypes(__DIR__ . '/data/repositoryMatching-collection26.php'); + } else { + yield from $this->gatherAssertTypes(__DIR__ . '/data/repositoryMatching-collection25.php'); + } } /** diff --git a/tests/DoctrineIntegration/data/repositoryMatching-collection25.php b/tests/DoctrineIntegration/data/repositoryMatching-collection25.php new file mode 100644 index 00000000..6c40f342 --- /dev/null +++ b/tests/DoctrineIntegration/data/repositoryMatching-collection25.php @@ -0,0 +1,45 @@ +entityManager->getRepository(MyEntity::class); + $criteria = Criteria::create(); + $results = $repository->matching($criteria); + assertType('Doctrine\Common\Collections\AbstractLazyCollection&Doctrine\Common\Collections\Selectable', $results); + foreach ($results as $result) { + assertType(MyEntity::class, $result); + } + } + + /** + * @param EntityRepository $repository + */ + public function withTypedRepository(EntityRepository $repository): void + { + $criteria = Criteria::create(); + $results = $repository->matching($criteria); + assertType('Doctrine\Common\Collections\AbstractLazyCollection&Doctrine\Common\Collections\Selectable', $results); + foreach ($results as $result) { + assertType(MyEntity::class, $result); + } + } + +} + +class MyEntity +{ + +} diff --git a/tests/DoctrineIntegration/data/repositoryMatching.php b/tests/DoctrineIntegration/data/repositoryMatching-collection26.php similarity index 100% rename from tests/DoctrineIntegration/data/repositoryMatching.php rename to tests/DoctrineIntegration/data/repositoryMatching-collection26.php From 7133f865e9cdb4581eb6e7fb7055e10f1d9755bb Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Tue, 5 May 2026 09:09:38 +0200 Subject: [PATCH 8/9] Feedback --- .../data/repositoryMatching-collection25.php | 2 +- .../data/repositoryMatching-collection26.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/DoctrineIntegration/data/repositoryMatching-collection25.php b/tests/DoctrineIntegration/data/repositoryMatching-collection25.php index 6c40f342..4378da88 100644 --- a/tests/DoctrineIntegration/data/repositoryMatching-collection25.php +++ b/tests/DoctrineIntegration/data/repositoryMatching-collection25.php @@ -1,6 +1,6 @@ Date: Tue, 5 May 2026 09:56:22 +0200 Subject: [PATCH 9/9] Fix tests --- .../data/repositoryMatching-collection25.php | 4 ++-- .../data/repositoryMatching-collection26.php | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/DoctrineIntegration/data/repositoryMatching-collection25.php b/tests/DoctrineIntegration/data/repositoryMatching-collection25.php index 4378da88..9aabef83 100644 --- a/tests/DoctrineIntegration/data/repositoryMatching-collection25.php +++ b/tests/DoctrineIntegration/data/repositoryMatching-collection25.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&Doctrine\Common\Collections\Selectable', $results); + assertType('Doctrine\Common\Collections\AbstractLazyCollection&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&Doctrine\Common\Collections\Selectable', $results); + assertType('Doctrine\Common\Collections\AbstractLazyCollection&Doctrine\Common\Collections\Selectable', $results); foreach ($results as $result) { assertType(MyEntity::class, $result); } diff --git a/tests/DoctrineIntegration/data/repositoryMatching-collection26.php b/tests/DoctrineIntegration/data/repositoryMatching-collection26.php index 1932ff1b..f76dfa30 100644 --- a/tests/DoctrineIntegration/data/repositoryMatching-collection26.php +++ b/tests/DoctrineIntegration/data/repositoryMatching-collection26.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\AbstractLazyCollection', $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\AbstractLazyCollection', $results); foreach ($results as $result) { assertType(MyEntity::class, $result); }