Diagnosing Missing Inserts in Doctrine ORM After persist() and flush()
Your entity vanishes after flush() with no error. This guide walks through the five most common Doctrine ORM causes—missing identifier strategies, uncommitted transactions, absent cascade persist, detached proxies, and swallowed exceptions—with ordered checks and fixes.
05 Mar 2026, 05:56 UTC

The Silent Missing Row
You call $entityManager->persist($entity) followed by $entityManager->flush(), yet the database table remains empty. No exception surfaces in your logs, and the application continues as if the save succeeded. This is one of the most common Doctrine ORM persistence failures, and it usually stems from a mismatch between how the ORM tracks state and how the database expects to receive the row.
Recognizable Condition
The symptom is straightforward: after flush() returns normally, a SELECT against the target table returns no matching row. In some variants, a related child entity fails to insert, producing a foreign key violation only if constraint checking is active. The failure is silent because Doctrine treats some conditions as "nothing to do" rather than as errors.
Quick Cause and Diagnostic Table
| Symptom | Likely Cause | First Check |
|---|---|---|
| No SQL INSERT logged | Primary key lacks auto-generation | Inspect @ORM\GeneratedValue |
| INSERT logged but row absent | Transaction never committed | Verify commit after flush |
| Parent saved, child missing | Missing cascade persist | Check owning-side association |
| Entity was loaded earlier, now ignored | Detached proxy or closed EntityManager | Confirm EntityManager is open |
| Exception thrown but swallowed | Error handler discards ORM exception | Review exception logging |
Ordered Diagnostic Checks
Work through these checks in order. Each step assumes the previous one passed.
1. Verify the Identifier Mapping
Doctrine needs to know how the primary key is generated. If the mapping does not define a generation strategy, Doctrine may consider the identifier already assigned and skip the INSERT. Check your entity mapping for a @ORM\GeneratedValue annotation (or the equivalent XML/YAML attribute) on the @ORM\Id property.
// Entity mapping (annotations)
#[Entity]
class Product
{
#[Id]
#[Column(type: 'integer')]
#[GeneratedValue(strategy: 'AUTO')] // or SEQUENCE / IDENTITY
private ?int $id = null;
}
If the strategy is missing, add it. Ensure the underlying database column is configured for auto-increment (MySQL/MariaDB) or backed by a sequence (PostgreSQL). Doctrine cannot insert a row with a NULL primary key unless the database fills it in.
2. Confirm Transaction Boundaries
Calling flush() sends SQL to the database, but it does not guarantee durability by itself. If you opened a transaction manually and never committed it, the database rolls back the insert when the connection closes or the transaction ends. Place this logic in your application service or command handler where persistence occurs.
$em->getConnection()->beginTransaction();
try {
$em->persist($product);
$em->flush();
$em->getConnection()->commit();
} catch (\Throwable $e) {
$em->getConnection()->rollBack();
throw $e;
}
If you are not managing transactions explicitly, confirm that your framework or connection is not left in an open transaction state. In many setups, flush() inside a transaction requires an explicit commit() call afterward.
3. Check Cascade Persist on Relationships
When you persist a parent entity that references a new child entity, Doctrine does not automatically persist the child unless cascade persist is configured. The parent insert may proceed, but the child insert is skipped, or a foreign key constraint fails. Add the cascade option in the entity association mapping.
#[OneToMany(targetEntity: OrderItem::class, mappedBy: 'order', cascade: ['persist'])]
private Collection $items;
Add cascade: ['persist'] on the owning side or the inverse side where you reach the new entity. Without it, you must call persist() on each new child individually.
4. Inspect EntityManager and Proxy State
An EntityManager that has been closed cannot track new changes. Similarly, a proxy object that has been detached from the persistence context will not be managed by persist(). If your entity was hydrated in a previous request or a long-running worker, it may be detached.
If you must reattach a detached entity, use merge() instead of persist(). merge() returns a managed copy; persist() on a detached entity does nothing.
$managed = $em->merge($detachedEntity);
$em->flush();
5. Rule Out Swallowed Exceptions
A flush() can throw an exception—for example, a unique constraint violation or a lost database connection—that your application catches and logs nowhere. If your global error handler suppresses exceptions, the entity appears unsaved without any visible failure.
Enable Doctrine SQL logging and ensure exceptions propagate to your logging pipeline. In development, set the connection to log queries so you can see whether an INSERT was actually attempted.
Fixes Tied to Findings
- Missing GeneratedValue: Add the annotation or attribute and verify the schema matches. Run schema validation if your setup supports it.
- Uncommitted transaction: Add an explicit
commit()afterflush(), or remove the manual transaction wrapper if the framework manages commits. - No cascade persist: Add
cascade: ['persist']to the association, or persist each new entity manually beforeflush(). - Detached entity or closed manager: Use
merge()for detached instances. Reopen or recreate the EntityManager if it was closed. - Silent exception: Remove empty catch blocks around persistence code. Configure a logger (such as Monolog) to capture Doctrine exceptions and SQL errors.
Concrete Example: From Broken to Working
The following configuration illustrates a common failure and the corrected version.
Broken
#[Entity]
class Invoice
{
#[Id]
#[Column(type: 'integer')]
private ?int $id = null; // No GeneratedValue
#[OneToMany(targetEntity: Line::class, mappedBy: 'invoice')]
private Collection $lines; // No cascade persist
}
Corrected
#[Entity]
class Invoice
{
#[Id]
#[Column(type: 'integer')]
#[GeneratedValue(strategy: 'AUTO')]
private ?int $id = null;
#[OneToMany(targetEntity: Line::class, mappedBy: 'invoice', cascade: ['persist'])]
private Collection $lines;
}
In the corrected version, the database can assign the identifier, and new Line entities reach the database when the Invoice is flushed.
Escalation Criteria
Move beyond application-level debugging when:
- The SQL log shows a successful INSERT and COMMIT, but the row is absent afterward. This points to replication lag, a read/write split targeting a replica, or a database trigger deleting the row.
- Doctrine schema validation reports a mismatch that migrations do not resolve. A DBA should compare the live schema against the expected mapping.
- The failure occurs only under high concurrency, suggesting isolation-level or locking issues rather than mapping errors.
Limitations and Verification
These checks assume a standard Doctrine ORM setup (version 2.x or 3.x with the DBAL layer). Behavior can differ if you use a custom ID generator, a non-relational database, or a framework that wraps the EntityManager in its own transaction decorator. Always verify against your specific platform.
To verify a fix, run the persistence code and then query the database directly—not through the same EntityManager, which may return a cached managed instance. A direct SQL SELECT confirms the row exists independently of the ORM identity map.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.