Identity and the Identity Map

Why two fetches of one row give you the same object, what detached means, and when clear() is the fix.

Syntax$entityManager->clear();

Fetch row 7 twice through the same entity manager and you get back the same PHP object: the identical instance, not a copy. The manager keeps an identity map, a table from class plus identifier to the object it already built. A second find() for an identifier it holds reads the map and sends no query.

The reason is change tracking. One row means one in-memory object, so there is exactly one thing to compare against its snapshot at flush time. Two copies of row 7 with different titles would have no correct answer.

Four states

  • New: constructed, never passed to persist, no identity yet.
  • Managed: in the identity map; its changes are written at the next flush.
  • Removed: scheduled for deletion at the next flush.
  • Detached: the manager has let go of it, and changes to it are ignored.

contains($entity) says which side of that line an object is on. detach($entity) drops one object. clear() drops all of them.

Long-running work

Every entity you fetch stays in the map until it is cleared, so a command walking a large table grows until memory runs out. The fix is to flush and clear in batches. After a clear, any variable still pointing at an old entity holds a detached object: setters on it change nothing at the next flush, and ORM 3 has no way to reattach it. Fetch it again by identifier.

Example

Example · php
<?php
// src/Service/IdentityInspector.php

declare(strict_types=1);

namespace App\Service;

use App\Entity\Article;
use Doctrine\ORM\EntityManagerInterface;

final class IdentityInspector
{
    public function __construct(private readonly EntityManagerInterface $entityManager)
    {
    }

    public function describe(int $id): string
    {
        // One SELECT runs here.
        $first = $this->entityManager->find(Article::class, $id);

        // No SELECT: the identity map already holds this id.
        $second = $this->entityManager->find(Article::class, $id);

        // Empties the identity map; $first is detached from here on.
        $this->entityManager->clear();

        // The map is empty, so this is a second SELECT and a new object.
        $third = $this->entityManager->find(Article::class, $id);

        return sprintf(
            'same after two finds: %s; same after clear: %s',
            $first === $second ? 'yes' : 'no',
            $first === $third ? 'yes' : 'no',
        );
    }
}

// Output: same after two finds: yes; same after clear: no

When to use it

  • Explain why an object fetched twice in one request is the same instance.
  • Debug a save that silently does nothing after a clear() somewhere upstream.
  • Keep a nightly command's memory flat while it walks a large table.
  • Decide whether a stale object needs refetching rather than reattaching.

More examples

The detached object trap

This is the failure people report as "Doctrine is not saving my changes". A clear, or a batch loop that clears, quietly detaches every object you are holding, and an UPDATE for a detached object is never generated: no exception, no row changed. contains() is the way to check an object's state when you are unsure, and refetching by identifier is the only cure.

Example · php
<?php
// src/Service/TitleTrimmer.php

declare(strict_types=1);

namespace App\Service;

use App\Entity\Article;
use Doctrine\ORM\EntityManagerInterface;

final class TitleTrimmer
{
    public function __construct(private readonly EntityManagerInterface $entityManager)
    {
    }

    public function trim(int $id): bool
    {
        $article = $this->entityManager->find(Article::class, $id);

        if ($article === null) {
            return false;
        }

        $this->entityManager->clear();

        // $article is detached now, so this change is invisible to flush():
        // $article->setTitle(trim($article->getTitle()));
        // $this->entityManager->flush();   // writes nothing

        // Fetch it again. ORM 3 cannot reattach the old instance.
        $managed = $this->entityManager->find(Article::class, $id);
        $managed->setTitle(trim($managed->getTitle()));
        $this->entityManager->flush();

        return $this->entityManager->contains($article); // the old one
    }
}

// Output: false

Batching a long-running command

The documented bulk pattern: flush every N objects, then clear so the identity map does not keep all of them alive. Pulling the identifiers first with one DBAL query keeps the loop's memory flat, and each find() loads one row at a time instead of the whole table. Note the final flush and clear after the loop, for the remainder that did not fill a batch.

Example · php
<?php
// src/Command/TidyTitlesCommand.php

declare(strict_types=1);

namespace App\Command;

use App\Entity\Article;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Output\OutputInterface;

#[AsCommand(name: 'app:articles:tidy-titles', description: 'Trims whitespace from every article title')]
final class TidyTitlesCommand
{
    private const int BATCH_SIZE = 100;

    public function __construct(
        private readonly EntityManagerInterface $entityManager,
        private readonly Connection $connection,
    ) {
    }

    public function __invoke(OutputInterface $output): int
    {
        // Identifiers only: a list of integers costs almost nothing to hold.
        $ids = $this->connection->fetchFirstColumn('SELECT id FROM article ORDER BY id');
        $done = 0;

        foreach ($ids as $id) {
            $article = $this->entityManager->find(Article::class, (int) $id);

            if ($article !== null) {
                $article->setTitle(trim($article->getTitle()));
            }

            if (++$done % self::BATCH_SIZE === 0) {
                $this->entityManager->flush();
                $this->entityManager->clear();
            }
        }

        $this->entityManager->flush();
        $this->entityManager->clear();
        $output->writeln(sprintf('Tidied %d titles.', $done));

        return Command::SUCCESS;
    }
}

// php bin/console app:articles:tidy-titles
// Tidied 2500 titles.

Discussion

  • Be the first to comment on this lesson.