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.

Syntax$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

Example · php
<?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.

Example · php
<?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: true

One 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.

Example · php
<?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.

Example · php
<?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

  • Be the first to comment on this lesson.