Transactions
A transaction makes several database writes succeed or fail together, and a failed flush leaves the entity manager closed.
$entityManager->wrapInTransaction(callable $func): mixedA transaction is a group of statements the database treats as one unit. Either all of them take effect or none do. You need one whenever two writes are only correct together: taking stock off a product and recording the order that consumed it.
flush already opens one
Every flush() works out the INSERT, UPDATE and DELETE statements it owes the database and sends them inside a single transaction. So one flush() is already atomic. You open a transaction yourself when a unit of work spans more than one flush(), or mixes flush() with SQL you run yourself.
The short form and the long form
wrapInTransaction() is the shorthand. It begins a transaction, runs your callback, calls flush(), commits, and returns whatever the callback returned. If anything throws, it rolls back, closes the entity manager and rethrows. The longhand is the same five steps by hand: beginTransaction(), your work, flush(), commit(), and rollback() in a catch block. They do the same thing; the short form cannot forget the rollback.
After a failed flush the manager is dead
When flush() throws, Doctrine closes the entity manager. Any further use of it throws, and the objects it holds no longer match the database, so there is nothing sensible to retry with. To carry on in the same process, ask the manager registry for a replacement with resetManager(). Anything that was handed the old manager still holds the closed one, which is why services that must survive a failure inject the registry instead.
In ORM 3 the older name transactional() no longer exists; wrapInTransaction() replaced it.
Example
<?php
// src/Service/ArticleArchiver.php
declare(strict_types=1);
namespace App\Service;
use App\Entity\ArchiveBatch;
use App\Entity\Article;
use Doctrine\ORM\EntityManagerInterface;
final class ArticleArchiver
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
) {
}
/**
* Either the batch row exists and every article points at it,
* or the database is left exactly as it was.
*
* @param list<Article> $articles
*/
public function archive(array $articles): ArchiveBatch
{
return $this->entityManager->wrapInTransaction(
function (EntityManagerInterface $em) use ($articles): ArchiveBatch {
$batch = new ArchiveBatch(count($articles));
$em->persist($batch); // schedules the insert
foreach ($articles as $article) {
$article->archive($batch); // changes a loaded entity
}
// No flush() here: wrapInTransaction() flushes before it commits.
return $batch;
},
);
}
}When to use it
- Create an order and decrement stock so that a failure leaves neither change behind.
- Run a multi-step import where each step flushes, and undo the whole batch on error.
- Recover inside a long-running command after one row fails to save.
More examples
The same method written out longhand
This is what wrapInTransaction() expands to, and it is worth reading once so you recognise the pattern in older code. Reach for the long form only when you need something in between the steps, such as committing and then running a statement that cannot be inside a transaction. Note how much there is to forget: the rollback, the close, and rethrowing rather than swallowing the exception.
<?php
// src/Service/ArticleArchiverLongForm.php
declare(strict_types=1);
namespace App\Service;
use App\Entity\ArchiveBatch;
use App\Entity\Article;
use Doctrine\ORM\EntityManagerInterface;
use Throwable;
final class ArticleArchiverLongForm
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
) {
}
/** @param list<Article> $articles */
public function archive(array $articles): ArchiveBatch
{
$this->entityManager->beginTransaction();
try {
$batch = new ArchiveBatch(count($articles));
$this->entityManager->persist($batch);
foreach ($articles as $article) {
$article->archive($batch);
}
$this->entityManager->flush(); // the short form does this step for you
$this->entityManager->commit();
return $batch;
} catch (Throwable $e) {
$this->entityManager->rollback();
$this->entityManager->close(); // the manager is not reusable
throw $e;
}
}
}Carrying on after a failed flush
A long-running import must survive one bad row. Because a failed flush closes the manager, the only way to continue is to obtain a fresh one, and the only way to obtain a fresh one is through the registry. If you had injected EntityManagerInterface into a property, every later loop would use the closed instance and throw.
<?php
// src/Service/AccountImporter.php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Account;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry;
final class AccountImporter
{
public function __construct(
private readonly ManagerRegistry $registry,
) {
}
/** @param list<string> $emails */
public function import(array $emails): int
{
$imported = 0;
foreach ($emails as $email) {
// Ask the registry every time; never cache the manager in a property.
$em = $this->manager();
try {
$em->persist(new Account($email));
$em->flush();
++$imported;
} catch (UniqueConstraintViolationException) {
// That flush wrote nothing and closed this manager.
$this->registry->resetManager();
}
}
return $imported;
}
private function manager(): EntityManagerInterface
{
$em = $this->registry->getManager();
assert($em instanceof EntityManagerInterface);
return $em;
}
}
Discussion