The Entity Manager
persist, flush and remove, the unit of work that batches them, and why flush is the only line that talks to the database.
$entityManager->persist($entity); $entityManager->flush();The entity manager moves entities between PHP and the database. You obtain it by type-hinting EntityManagerInterface on a constructor parameter; the container injects it.
Its three write methods do less than their names suggest. persist($entity) asks the manager to start tracking a new object. remove($entity) marks a tracked object for deletion. Neither sends SQL. flush() does: it inspects everything it is tracking, works out the INSERT, UPDATE and DELETE statements needed, and sends them inside a single transaction.
The unit of work
The unit of work is the bookkeeping behind that. When an object is loaded or persisted it becomes managed, and the manager stores a snapshot of its properties. At flush time it compares each managed object against its snapshot, and the differences become UPDATE statements. This is why changing a loaded entity needs no persist() call. persist is only for objects Doctrine has never seen.
What trips people up
Nothing calls flush for you. A request that persists and never flushes writes nothing and reports no error. A generated identifier is null until flush has run. And if flush throws, the transaction is rolled back and the entity manager is closed: everything it held becomes detached and further calls throw, so catching the exception and carrying on with the same manager does not work. You need a fresh one.
Example
<?php
// src/Service/ArticlePublisher.php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Article;
use Doctrine\ORM\EntityManagerInterface;
final class ArticlePublisher
{
public function __construct(private readonly EntityManagerInterface $entityManager)
{
}
public function publish(string $title, string $body): int
{
$article = new Article();
$article->setTitle($title);
$article->setBody($body);
// Doctrine has never seen this object, so it has to be told.
// No SQL runs here, and getId() is still null.
$this->entityManager->persist($article);
// One transaction, one INSERT. The id is set on the object after this.
$this->entityManager->flush();
return (int) $article->getId();
}
public function retitle(int $id, string $title): void
{
$article = $this->entityManager->find(Article::class, $id);
if ($article === null) {
return;
}
// Already managed, because it came from Doctrine: no persist() needed.
$article->setTitle($title);
$this->entityManager->flush();
}
}
// Statements sent by the two flush() calls:
// INSERT INTO article (title, body) VALUES (?, ?)
// UPDATE article SET title = ? WHERE id = ?When to use it
- Save a new record from a controller and return its generated id.
- Change a loaded record without wondering whether persist() is needed.
- Make two related writes succeed or fail together.
- Turn a duplicate-key failure into a readable message instead of a 500.
More examples
remove() schedules, flush() deletes
remove() is the mirror of persist(): it schedules work rather than doing it. Between the two calls the entity is still readable and still returned by queries, which surprises people writing a delete-then-report flow. After flush the object survives in PHP with its values intact, so reading the title for a log line or a message is safe.
<?php
// src/Service/ArticleDeleter.php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Article;
use Doctrine\ORM\EntityManagerInterface;
final class ArticleDeleter
{
public function __construct(private readonly EntityManagerInterface $entityManager)
{
}
public function delete(int $id): bool
{
$article = $this->entityManager->find(Article::class, $id);
if ($article === null) {
return false;
}
$this->entityManager->remove($article);
// The row is still there and getTitle() still works.
$this->entityManager->flush();
// Now the DELETE has run. The PHP object keeps its values; it is
// simply no longer managed.
return true;
}
}
// Output: trueOne transaction: the shorthand and the longhand
A single flush is already one transaction, so you need these only when two flushes, or a flush and a raw statement, must succeed together. wrapInTransaction(callable $func) is the short form and worth preferring: the long form is where people forget either the rollBack or the close() that Doctrine's documented behaviour expects.
<?php
// src/Service/ArticleArchiver.php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Article;
use Doctrine\ORM\EntityManagerInterface;
use Throwable;
final class ArticleArchiver
{
public function __construct(private readonly EntityManagerInterface $entityManager)
{
}
public function archive(Article $article): void
{
// Shorthand: flushes before commit, and on an exception rolls back
// and closes the entity manager before rethrowing.
$this->entityManager->wrapInTransaction(static function (EntityManagerInterface $em) use ($article): void {
$article->setBody('');
$em->persist(new Article());
});
}
public function archiveTheLongWay(Article $article): void
{
// The same thing, written out.
$connection = $this->entityManager->getConnection();
$connection->beginTransaction();
try {
$article->setBody('');
$this->entityManager->persist(new Article());
$this->entityManager->flush();
$connection->commit();
} catch (Throwable $e) {
$connection->rollBack();
$this->entityManager->close();
throw $e;
}
}
}A failed flush closes the manager
Database-level failures surface as DBAL exceptions during flush, and a duplicate key is the common one. Catch it when you want a readable message instead of a 500, but do not plan to keep using the same entity manager afterwards: it is closed, every entity it held is detached, and the rest of the request has to work without it.
<?php
// src/Service/ArticleImporter.php
declare(strict_types=1);
namespace App\Service;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
final class ArticleImporter
{
public function __construct(private readonly EntityManagerInterface $entityManager)
{
}
public function save(): bool
{
try {
$this->entityManager->flush();
} catch (UniqueConstraintViolationException $e) {
// The transaction was rolled back and the manager is now closed:
// isOpen() returns false and any further flush() throws.
return false;
}
return true;
}
}
// Output: false
Discussion