From 375eadda2b8504db2161c47dbb420e74662c116e Mon Sep 17 00:00:00 2001 From: Jan Lam Date: Wed, 14 Oct 2026 14:28:37 +0200 Subject: [PATCH 1/5] Support doctrine/orm ^3.0 alongside ^2.19.7 Code changes needed for ORM 3, verified to keep working on ORM 2.19.7+ as well by running the full suite against both: - Doctrine\ORM\Mapping\ClassMetadataInfo is removed entirely in ORM 3; its ONE_TO_ONE/TO_MANY constants now live directly on ClassMetadata. - ORM 3's now-default native lazy objects are plain instances of the entity's own class, not a generated proxy subclass implementing Doctrine\Persistence\Proxy, so `$entity instanceof Proxy` silently stops detecting uninitialized entities. Replaced with UnitOfWork::isUninitializedObject(), which exists on both ORM 2.19.7+ and ORM 3 and is the mechanism ORM 3 itself uses internally. Also declare doctrine/dbal and doctrine/collections directly, since EntityMutationMetadataProvider/EntityChangedEvent use their classes (DriverManager in tests, ArrayCollection/Collection) directly rather than only transitively through doctrine/orm. Co-Authored-By: Claude Sonnet 5 --- composer.json | 8 ++++-- src/Listener/EntityChangedListener.php | 7 ++--- .../EntityMutationMetadataProvider.php | 5 ++-- test/Listener/EntityChangedListenerTest.php | 28 +++++++++++++------ 4 files changed, 29 insertions(+), 19 deletions(-) diff --git a/composer.json b/composer.json index 1c049be..096c894 100644 --- a/composer.json +++ b/composer.json @@ -5,16 +5,18 @@ "minimum-stability": "stable", "require": { "php": "^8.3", + "doctrine/collections": "^1.8.0|^2.2", "doctrine/common": "^3.4.4", + "doctrine/dbal": "^3.2|^4.0", "doctrine/event-manager": "^1.2|^2.0", - "doctrine/orm": "^2.19.7", + "doctrine/orm": "^2.19.7|^3.0", "doctrine/persistence": "^2.5.7|^3.4.0", "psr/log": "^1.0.0|^2.0.0|^3.0.0", "symfony/cache": "^6.4|^7.4" }, "require-dev": { - "hostnet/database-test-lib": "^2.0.2", - "hostnet/phpcs-tool": "^9.1.0", + "hostnet/database-test-lib": "^2.0.6", + "hostnet/phpcs-tool": "^9.1.5", "phpspec/prophecy-phpunit": "^2.5.0", "phpunit/phpunit": "^9.4" }, diff --git a/src/Listener/EntityChangedListener.php b/src/Listener/EntityChangedListener.php index 5270338..b322cb3 100644 --- a/src/Listener/EntityChangedListener.php +++ b/src/Listener/EntityChangedListener.php @@ -6,9 +6,8 @@ namespace Hostnet\Component\EntityTracker\Listener; +use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Event\PreFlushEventArgs; -use Doctrine\Persistence\ObjectManager; -use Doctrine\Persistence\Proxy; use Hostnet\Component\EntityTracker\Attributes\Tracked; use Hostnet\Component\EntityTracker\Event\EntityChangedEvent; use Hostnet\Component\EntityTracker\Events; @@ -37,7 +36,7 @@ public function __construct( $this->logger = $logger ? : new NullLogger(); } - private function isTracked(ObjectManager $em, object $entity): bool + private function isTracked(EntityManagerInterface $em, object $entity): bool { $cache_key = base64_encode('TRACKED-' . get_class($entity)); $cached_item = $this->is_tracked_cache->getItem($cache_key); @@ -86,7 +85,7 @@ public function preFlush(PreFlushEventArgs $event): void } foreach ($updates as $entity) { - if ($entity instanceof Proxy && !$entity->__isInitialized()) { + if ($em->getUnitOfWork()->isUninitializedObject($entity)) { continue; } diff --git a/src/Provider/EntityMutationMetadataProvider.php b/src/Provider/EntityMutationMetadataProvider.php index aa75949..157f59a 100644 --- a/src/Provider/EntityMutationMetadataProvider.php +++ b/src/Provider/EntityMutationMetadataProvider.php @@ -9,7 +9,6 @@ use Doctrine\Common\Collections\Collection; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Mapping\ClassMetadata; -use Doctrine\ORM\Mapping\ClassMetadataInfo; use Doctrine\ORM\PersistentCollection; use Doctrine\ORM\UnitOfWork; use Psr\Log\LoggerInterface; @@ -68,11 +67,11 @@ public function getMutatedFields(EntityManagerInterface $em, object $entity, ?ob $associations = []; foreach ($metadata->getAssociationMappings() as $name => $mapping) { - if ($mapping['type'] === ClassMetadataInfo::ONE_TO_ONE && !$mapping['isOwningSide']) { + if ($mapping['type'] === ClassMetadata::ONE_TO_ONE && !$mapping['isOwningSide']) { continue; } - if ($mapping['type'] & ClassMetadataInfo::TO_MANY) { + if ($mapping['type'] & ClassMetadata::TO_MANY) { continue; } $associations[] = $name; diff --git a/test/Listener/EntityChangedListenerTest.php b/test/Listener/EntityChangedListenerTest.php index d3140e4..55601c5 100644 --- a/test/Listener/EntityChangedListenerTest.php +++ b/test/Listener/EntityChangedListenerTest.php @@ -9,7 +9,7 @@ use Doctrine\Common\EventManager; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Event\PreFlushEventArgs; -use Doctrine\Persistence\Proxy; +use Doctrine\ORM\UnitOfWork; use Hostnet\Component\EntityTracker\Attributes\Tracked; use Hostnet\Component\EntityTracker\Event\EntityChangedEvent; use Hostnet\Component\EntityTracker\Events; @@ -33,6 +33,7 @@ class EntityChangedListenerTest extends TestCase private $meta_mutation_provider; private $event_manager; private $em; + private $unit_of_work; private $logger; private $event; @@ -46,11 +47,13 @@ protected function setUp(): void $this->meta_provider = $this->prophesize(EntityMetadataProvider::class); $this->meta_mutation_provider = $this->prophesize(EntityMutationMetadataProvider::class); $this->em = $this->prophesize(EntityManagerInterface::class); + $this->unit_of_work = $this->prophesize(UnitOfWork::class); $this->event = $this->prophesize(PreFlushEventArgs::class); $this->event_manager = $this->prophesize(EventManager::class); $this->logger = $this->prophesize(LoggerInterface::class); $this->em->getEventManager()->willReturn($this->event_manager->reveal()); + $this->em->getUnitOfWork()->willReturn($this->unit_of_work->reveal()); $this->listener = new EntityChangedListener( $this->meta_provider->reveal(), @@ -93,6 +96,7 @@ public function testPreFlushUnmanaged(): void $this->meta_provider ->getAttributeFromEntity(Argument::any(), $this->em->reveal(), $entity) ->willReturn(new Tracked()); + $this->unit_of_work->isUninitializedObject($entity)->willReturn(false); $this->logger->debug(Argument::cetera())->shouldBeCalled(); $this->meta_mutation_provider->isEntityManaged($this->em->reveal(), $entity)->willReturn(true); $this->meta_mutation_provider->createOriginalEntity($this->em->reveal(), $entity)->willReturn(null); @@ -118,6 +122,7 @@ public function testPreFlushWithoutMutatedFields(): void $this->meta_provider ->getAttributeFromEntity(Argument::any(), $this->em->reveal(), $entity) ->willReturn(new Tracked()); + $this->unit_of_work->isUninitializedObject($entity)->willReturn(false); $this->meta_mutation_provider->isEntityManaged($this->em->reveal(), $entity)->willReturn(true); $this->meta_mutation_provider->createOriginalEntity($this->em->reveal(), $entity)->willReturn($original); $this->meta_mutation_provider->getMutatedFields($this->em->reveal(), $entity, $entity)->willReturn([]); @@ -138,6 +143,7 @@ public function testPreFlushWithMutatedFields(): void $this->meta_provider ->getAttributeFromEntity(Argument::any(), $this->em->reveal(), $entity) ->willReturn(new Tracked()); + $this->unit_of_work->isUninitializedObject($entity)->willReturn(false); $this->logger->debug(Argument::cetera())->shouldBeCalled(); $this->meta_mutation_provider->isEntityManaged($this->em->reveal(), $entity)->willReturn(true); $this->meta_mutation_provider->createOriginalEntity($this->em->reveal(), $entity)->willReturn($original); @@ -159,6 +165,7 @@ public function testPreFlushWithNewAttributedEntity(): void $this->meta_provider ->getAttributeFromEntity(Tracked::class, $this->em->reveal(), $entity) ->willReturn(new Tracked())->shouldBeCalled(); + $this->unit_of_work->isUninitializedObject($entity)->willReturn(false); $this->logger->debug(Argument::cetera())->shouldBeCalled(); $this->meta_mutation_provider->isEntityManaged($this->em->reveal(), $entity)->willReturn(true); $this->meta_mutation_provider->createOriginalEntity($this->em->reveal(), $entity)->willReturn(null); @@ -186,6 +193,8 @@ public function testPreFlushWithNewEntityCached(): void $this->meta_provider ->getAttributeFromEntity(Tracked::class, $this->em->reveal(), $entity) ->willReturn(new Tracked())->shouldBeCalledTimes(1); + $this->unit_of_work->isUninitializedObject($entity)->willReturn(false); + $this->unit_of_work->isUninitializedObject($entity_2nd_flush)->willReturn(false); $this->event_manager ->dispatchEvent(Events::ENTITY_CHANGED, Argument::type(EntityChangedEvent::class)) ->shouldBeCalledTimes(2); @@ -194,9 +203,9 @@ public function testPreFlushWithNewEntityCached(): void $this->listener->preFlush(new PreFlushEventArgs($this->em->reveal())); } - public function testPreFlushWithProxy(): void + public function testPreFlushWithUninitializedEntity(): void { - $entity = $this->prophesize(Proxy::class)->reveal(); + $entity = new \stdClass(); $this->meta_mutation_provider ->getFullChangeSet($this->em->reveal()) ->willReturn($this->genericEntityDataProvider($entity)); @@ -206,28 +215,29 @@ public function testPreFlushWithProxy(): void $this->meta_provider ->getAttributeFromEntity(Argument::any(), $this->em->reveal(), $entity) ->willReturn(new Tracked()); + $this->unit_of_work->isUninitializedObject($entity)->willReturn(true); $this->meta_mutation_provider->isEntityManaged($this->em->reveal(), $entity)->willReturn(true); $this->listener->preFlush(new PreFlushEventArgs($this->em->reveal())); } - public function testPreFlushWithInitializedProxy(): void + public function testPreFlushWithInitializedEntity(): void { $original = new \stdClass(); $original->id = 0; - $entity = $this->prophesize(Proxy::class); + $entity = new \stdClass(); $this->meta_mutation_provider ->getFullChangeSet($this->em->reveal()) - ->willReturn($this->genericEntityDataProvider($entity->reveal())); + ->willReturn($this->genericEntityDataProvider($entity)); $this->meta_provider ->getAttributeFromEntity(Argument::any(), $this->em->reveal(), $entity) ->willReturn(new Tracked()); - $this->meta_mutation_provider->isEntityManaged($this->em->reveal(), $entity->reveal())->willReturn(true); - $entity->__isInitialized()->willReturn(true); + $this->meta_mutation_provider->isEntityManaged($this->em->reveal(), $entity)->willReturn(true); + $this->unit_of_work->isUninitializedObject($entity)->willReturn(false); $this->logger->debug(Argument::cetera())->shouldBeCalled(); $this->meta_mutation_provider->createOriginalEntity($this->em->reveal(), $entity)->willReturn($original); $this->meta_mutation_provider - ->getMutatedFields($this->em->reveal(), $entity->reveal(), $original) + ->getMutatedFields($this->em->reveal(), $entity, $original) ->willReturn(['id']); $this->event_manager From 90807f84d80dd69c6611efa7857b97c099fa4f0e Mon Sep 17 00:00:00 2001 From: Jan Lam Date: Wed, 14 Oct 2026 14:52:26 +0200 Subject: [PATCH 2/5] Move doctrine/dbal from require to require-dev Co-Authored-By: Claude Sonnet 5 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 096c894..53a5fc2 100644 --- a/composer.json +++ b/composer.json @@ -7,7 +7,6 @@ "php": "^8.3", "doctrine/collections": "^1.8.0|^2.2", "doctrine/common": "^3.4.4", - "doctrine/dbal": "^3.2|^4.0", "doctrine/event-manager": "^1.2|^2.0", "doctrine/orm": "^2.19.7|^3.0", "doctrine/persistence": "^2.5.7|^3.4.0", @@ -15,6 +14,7 @@ "symfony/cache": "^6.4|^7.4" }, "require-dev": { + "doctrine/dbal": "^3.2|^4.0", "hostnet/database-test-lib": "^2.0.6", "hostnet/phpcs-tool": "^9.1.5", "phpspec/prophecy-phpunit": "^2.5.0", From 5de7673d69499c62a1f720dca8d38897fa52b2b4 Mon Sep 17 00:00:00 2001 From: Jan Lam Date: Wed, 14 Oct 2026 14:59:53 +0200 Subject: [PATCH 3/5] Run CI against both doctrine/orm ^2 and ^3 Co-Authored-By: Claude Sonnet 5 --- .github/workflows/main.yaml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 4e10169..8d0f4ca 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -13,7 +13,8 @@ jobs: strategy: matrix: php-versions: ['8.3', '8.4'] - name: PHP ${{ matrix.php-versions }} + doctrine-orm: ['^2.19.7', '^3.0'] + name: PHP ${{ matrix.php-versions }} / doctrine/orm ${{ matrix.doctrine-orm }} steps: - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 @@ -26,8 +27,10 @@ jobs: - uses: actions/cache@v4 with: path: ${{ steps.composer-cache.outputs.dir }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} - restore-keys: ${{ runner.os }}-composer- + key: ${{ runner.os }}-composer-${{ matrix.doctrine-orm }}-${{ hashFiles('**/composer.lock') }} + restore-keys: ${{ runner.os }}-composer-${{ matrix.doctrine-orm }}- + - name: Require doctrine/orm ${{ matrix.doctrine-orm }} + run: composer require doctrine/orm:${{ matrix.doctrine-orm }} --no-update - name: composer install run: composer install --no-progress --prefer-dist --optimize-autoloader - run: sudo /etc/init.d/mysql start From 9433d124d07ee3ae203edd58ded7b4835056b80f Mon Sep 17 00:00:00 2001 From: Jan Lam Date: Wed, 14 Oct 2026 15:02:49 +0200 Subject: [PATCH 4/5] Split phpcs into its own job; key composer cache on composer.json phpcs is doctrine/orm-version-independent, so running it once per matrix cell was pure duplicate work. composer.lock is gitignored here and never checked out in CI, so hashFiles('**/composer.lock') always evaluated to empty and the cache key never actually varied with dependency changes; composer.json is the file that's actually present and signals when the resolved graph might differ. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/main.yaml | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 8d0f4ca..5843ab9 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -27,7 +27,7 @@ jobs: - uses: actions/cache@v4 with: path: ${{ steps.composer-cache.outputs.dir }} - key: ${{ runner.os }}-composer-${{ matrix.doctrine-orm }}-${{ hashFiles('**/composer.lock') }} + key: ${{ runner.os }}-composer-${{ matrix.doctrine-orm }}-${{ hashFiles('**/composer.json') }} restore-keys: ${{ runner.os }}-composer-${{ matrix.doctrine-orm }}- - name: Require doctrine/orm ${{ matrix.doctrine-orm }} run: composer require doctrine/orm:${{ matrix.doctrine-orm }} --no-update @@ -36,5 +36,25 @@ jobs: - run: sudo /etc/init.d/mysql start - name: phpunit run: php vendor/bin/phpunit + + phpcs: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + - name: Get composer cache directory + id: composer-cache + run: | + echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + - uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: ${{ runner.os }}-composer- + - name: composer install + run: composer install --no-progress --prefer-dist --optimize-autoloader - name: phpcs run: php vendor/bin/phpcs From 3245562d7aec3d29262ee6347eba35cdffa2b2ec Mon Sep 17 00:00:00 2001 From: Jan Lam Date: Wed, 14 Oct 2026 15:15:56 +0200 Subject: [PATCH 5/5] Conflict symfony/var-exporter >=8.0 to fix CI with doctrine/orm ^3 ORM 3's ProxyFactory defaults to generating a proxy subclass via symfony/var-exporter's LazyGhostTrait (the same Doctrine\Persistence\Proxy-implementing shape ORM 2 has always used), unless Configuration::isNativeLazyObjectsEnabled() is explicitly turned on. On PHP 8.4, composer resolves symfony/var-exporter to 8.x, which removed that trait entirely; on PHP 8.3 it stays below 8.0, which is why only the PHP 8.4 / ORM 3 matrix cell failed. Co-Authored-By: Claude Sonnet 5 --- composer.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/composer.json b/composer.json index 53a5fc2..26b79d9 100644 --- a/composer.json +++ b/composer.json @@ -20,6 +20,9 @@ "phpspec/prophecy-phpunit": "^2.5.0", "phpunit/phpunit": "^9.4" }, + "conflict": { + "symfony/var-exporter": ">=8.0" + }, "autoload": { "psr-4": { "Hostnet\\Component\\EntityTracker\\": "src/"