Skip to content

fix(doctrine): do not replace new relations with assigned ids by refe… - #8498

Open
dylan-rumble wants to merge 1 commit into
api-platform:4.3from
dylan-rumble:fix/doctrine-persist-cascade-assigned-id
Open

fix(doctrine): do not replace new relations with assigned ids by refe…#8498
dylan-rumble wants to merge 1 commit into
api-platform:4.3from
dylan-rumble:fix/doctrine-persist-cascade-assigned-id

Conversation

@dylan-rumble

@dylan-rumble dylan-rumble commented Sep 2, 2026

Copy link
Copy Markdown
Q A
Branch? 4.3
Tickets Fixes #8438
License MIT
Doc PR n/a

Problem

When using the ObjectMapper (stateOptions + input DTO), PersistProcessor::handleLazyObjectRelations() replaces every unmanaged related object holding a non-null identifier with a getReference() proxy, assuming "has an id" means "row exists in the database".

That assumption only holds for #[ORM\GeneratedValue] entities (id is null pre-flush). With application-assigned identifiers (e.g. UUIDs), a freshly mapped nested entity is indistinguishable from a reference to an existing row: it gets swapped for a proxy, the proxy counts as managed, cascade: ['persist'] never fires, and the flush fails with a foreign key violation (or, on SQLite, the entity is "not found" on refresh).

Fix

Before creating a reference, ask the unit of work whether the object is genuinely new (UnitOfWork::getEntityState() for ORM, getDocumentState() for ODM) and skip it if it is STATE_NEW. Doctrine's own semantics give the right behavior for each case:

Tests

New Issue8438* fixtures (owning TaskRule with cascade: ['persist'] to TaskRuleCondition, both keyed by application-assigned UUIDs) and two functional tests in StateOptionTest:

  • testPostWithEntityClassOptionCascadePersistsNewRelationWithAssignedIdentifier — nested condition payload with a pre-assigned UUID. Failed before this fix, passes now.
  • testPostWithEntityClassOptionReferencesExistingRelationWithAssignedIdentifiercondition given as an IRI to an existing UUID-keyed row still resolves to a reference (no duplicate insert).

Note on the fixtures

The issue's example assigns the UUID in the entity constructor. With symfony/object-mapper ≥ 8.1, nested mapped targets are lazy ghosts whose initializer calls $mapper->map($value, $target) when the mapper is decorated (API Platform decorates it via api_platform.object_mapper.relation). That path never passes constructTarget, so the nested entity's constructor is never executed and a constructor-assigned id is never set. This cannot be detected downstream (isUninitializedLazyObject() is already false inside the initializer) and is a separate upstream object-mapper concern. The fixtures therefore assign the UUID in the DTO constructor, which exercises the exact PersistProcessor heuristic this PR fixes; the entity constructors still assign a UUID as in the issue.

@dylan-rumble
dylan-rumble marked this pull request as ready for review September 2, 2026 14:26
…rences

PersistProcessor::handleLazyObjectRelations() swapped any unmanaged related
object holding a non-null identifier with a Doctrine reference, assuming the
row already exists. With application-assigned identifiers (e.g. UUIDs), a
freshly mapped nested entity was therefore never cascade-persisted, causing a
foreign key violation on flush.

Consult the unit of work state (STATE_NEW) before creating a reference so
that new objects are left to cascade persist, while objects with
database-generated ids or existing rows are still replaced by references.

Fixes api-platform#8438
@dylan-rumble
dylan-rumble force-pushed the fix/doctrine-persist-cascade-assigned-id branch from 2177712 to 0e5f21f Compare September 2, 2026 15:03
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.

1 participant