api-platform / api-platform/core

ObjectMapper: PersistProcessor::handleLazyObjectRelations() breaks cascade persist of new relations with app-assigned ids (UUID in constructor)

Open
#8,438 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
PHP
Stars
2.6k
Forks
980
Avg merge
2d 2h
Merged PRs (30d)
51

Description

**API Platform version(s) affected**: 4.3.5 (also present on `4.3` and `main` branches)

**Description**

When using the ObjectMapper (`stateOptions` + input DTO) on a `Post` operation, creating a **new** nested related entity fails with a foreign key constraint violation when the related entity uses an **application-assigned identifier** (e.g. a UUID generated in the constructor).

`PersistProcessor::handleLazyObjectRelations()` runs for every mapped operation (`$operation->canMap()`) and replaces any relation value that is *not managed* but *has non-null identifiers* with `$manager->getReference($class, $identifiers)`:

```php
// Do not get reference for partial objects or objects with null identifiers
if (!$identifiers || \count($identifiers) !== \count(array_filter($identifiers, static fn ($v) => null !== $v))) {
continue;
}

$reflectionProperty->setValue($data, $relManager->getReference($relClass, $identifiers));
```

This heuristic assumes "has an id" means "row exists in the database". That holds for `#[ORM\GeneratedValue]` entities (id is null pre-flush, so the swap is skipped and `cascade: ['persist']` works — this is the case covered by the `Issue7689` fixtures where `Issue7689Category` has a nullable auto-increment id). But with app-assigned ids (UUIDv4/v7 assigned in the constructor, a common pattern), a freshly mapped nested entity is indistinguishable from a reference to an existing one:

1. The mapper creates a new related entity; its constructor assigns a UUID.
2. `handleLazyObjectRelations()` sees an unmanaged object with a non-null identifier and swaps it for a `getReference()` proxy.
3. The proxy counts as managed, so `cascade: ['persist']` on the owning side never persists it.
4. Flush inserts the owning entity with an FK pointing to a nonexistent row:

```
SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row:
a foreign key constraint fails (FOREIGN KEY ("condition_id") REFERENCES "..." ("id"))
```

**How to reproduce**

Entities (owning side cascades persist to the relation; both use constructor-assigned UUIDs):

```php
#[ORM\Entity]
class TaskRule
{
public function __construct(
#[ORM\ManyToOne(targetEntity: TaskRuleCondition::class, cascade: ['persist'])]
#[ORM\JoinColumn(nullable: true)]
public ?TaskRuleCondition $condition = null,
#[ORM\Id, ORM\Column]
private(set) UuidV7 $id = new UuidV7(),
) {
}
}

#[ORM\Entity]
class TaskRuleCondition
{
public function __construct(
#[ORM\Column]
public string $completeWhen,
#[ORM\Id, ORM\Column]
private(set) UuidV7 $id = new UuidV7(),
) {
}
}
```

Input DTOs mapped with the ObjectMapper:

```php
#[Map(target: TaskRule::class)]
final readonly class TaskRulePostInput
{
public function __construct(
public ?TaskRuleConditionInput $condition = null,
) {
}
}
```

`POST` with a nested `condition` payload -> FK violation on flush. Persisting the owning entity manually in a custom processor *before* `PersistProcessor` runs works around it, because the cascade marks the relation managed and `handleLazyObjectRelations()` then skips it via the `contains()` check.

**Possible Solution**

Only swap values that are genuinely placeholders for existing rows, instead of assuming any unmanaged object with an id is one:

- only replace actual uninitialized lazy objects (`ReflectionClass::isUninitializedLazyObject()` on PHP >= 8.4), and/or
- consult `UnitOfWork::getEntityState($value)` — `STATE_NEW` objects should be left alone so cascade persist can handle them.

**Additional Context**

- Introduced/extended in cc2f8855 ("fix(doctrine): post with mapped relation", #7689) — the accompanying test fixtures only cover DB-generated ids, where new entities still have null ids pre-flush.
- Related but distinct: #7735/#7738 fixed uninitialized-property crashes in the same method; #7801 concerns nested mapping on the output side.

Contributor guide

Open the contributing guide

Research direction

Start by locating PersistProcessor::handleLazyObjectRelations() and the existing Issue7689 fixtures/tests mentioned in the issue. Reproduce a mapped POST with a nested relation whose UUID is assigned in the constructor. Done means new unmanaged related entities with non-null ids are left for cascade persist, while references to existing rows still behave correctly.

Written by the indexing model from the issue text.

Assessment

Tech stack
php, symfony
Domain
api, backend, database
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
55/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.