Skip to content

Fix no-op for initializeObject() and isUninitializedObject() when object is not a mapped entity - #12617

Merged
greg0ire merged 1 commit into
doctrine:3.7.xfrom
SherinBloemendaal:fix-initialize-object-for-native-lazy-objects-no-op
Sep 21, 2026
Merged

greg0ire merged 1 commit into
doctrine:3.7.xfrom
SherinBloemendaal:fix-initialize-object-for-native-lazy-objects-no-op

Conversation

@SherinBloemendaal

Copy link
Copy Markdown
Contributor
Q A
Type bug
Fixed issues #12172

Summary

Supersedes #12173, which was auto-closed as stale and can no longer be reopened because its base branch 3.6.x has been deleted. Same change, rebased onto 3.7.x.

Fix initializeObject() and isUninitializedObject() to be a no-op for non-managed objects when native lazy objects are enabled.

With isNativeLazyObjectsEnabled(), both methods call getClassMetadata($obj::class) unconditionally, so passing a non-entity (a DTO, stdClass, …) throws a MappingException instead of the no-op the ObjectManager interface documents. This breaks e.g. Symfony's UniqueEntityValidator on DTOs with entityClass, which calls $em->initializeObject() on association field values.

This fix wraps the getClassMetadata() calls in try/catch blocks to handle non-entity objects gracefully while preserving functionality for valid Doctrine entities (including detached ones after clear()).

I went with the try/catch approach since isInIdentityMap() also internally calls getClassMetadata(), but I'm open to better solutions if there are any.

Changes:

  • UnitOfWork::initializeObject(): Add try/catch around metadata access
  • UnitOfWork::isUninitializedObject(): Add try/catch around metadata access
  • GH12172Test: functional test covering both methods for non-entities and for real lazy entities

Comment thread src/UnitOfWork.php
$reflection->initializeLazyObject($obj);
} catch (PersistenceMappingException) {
// No-op for non-Doctrine entities according to the ObjectManager::initializeObject() interface documentation.
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A try with an empty catch is an anti pattern I think. Have you considered using AbstractClassMetadataFactory::isTransient() instead?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, thanks. Switched both methods to an isTransient() guard and dropped the try/catch (5c03f84). The MappingDriverChain test cases stay in GH12172Test, since that driver returns true from isTransient() for a class outside every registered namespace — which is the Symfony case that triggered this.

One note: AbstractClassMetadataFactory::isTransient() always goes to the driver, so for an already-loaded entity class this is a ReflectionClass + attribute read per call, on the computeAssociationChanges() path. Probably fine, but if you'd rather avoid it I can add a hasMetadataFor() fast path — or that could be a small follow-up in doctrine/persistence (check loadedMetadata before asking the driver).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think a follow-up in doctrine/persistence would make sense. Actually, you could send it right now, it's orthogonal to this PR, isn't it?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried the bare isTransient() first and it broke ValueObjectsTest::testPartialDqlOnEmbeddedObjectsField: an embeddable loaded through a partial query is a lazy ghost, but isTransient() reports it as transient, since by contract it is only false for entities and mapped superclasses. So isUninitializedObject() returned false for an uninitialized embeddable ghost.

865fbf2 keeps isTransient() but checks hasMetadataFor() first. If this manager has already loaded metadata for the class, that covers embeddables (the ghost could not exist otherwise) and makes the common case an array lookup, which also takes care of the reflection cost. isTransient() remains as the fallback for an entity class this manager has not loaded yet.

I also drafted the doctrine/persistence change I floated earlier and then dropped it. Short-circuiting isTransient() on loadedMetadata would make it return false for embeddables (and ODM embedded documents) once loaded, which contradicts its documented contract and makes the answer depend on load order. The abstract factory cannot tell a mapped superclass from an embeddable via isEntity(), so I do not see a safe generic version. Sorry for the detour.

@SherinBloemendaal
SherinBloemendaal force-pushed the fix-initialize-object-for-native-lazy-objects-no-op branch from ea48587 to 5c03f84 Compare September 20, 2026 16:31
@SherinBloemendaal
SherinBloemendaal force-pushed the fix-initialize-object-for-native-lazy-objects-no-op branch from 5c03f84 to 865fbf2 Compare September 20, 2026 17:46
@greg0ire greg0ire added this to the 3.7.2 milestone Sep 21, 2026
@greg0ire
greg0ire merged commit 65ddba5 into doctrine:3.7.x Sep 21, 2026
131 checks passed
@greg0ire

Copy link
Copy Markdown
Member

Thanks @SherinBloemendaal !

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants