| name | symfony:doctrine-transactions |
| description | Handle database transactions with Doctrine UnitOfWork; implement optimistic locking, flush strategies, and transaction boundaries |
Doctrine Transactions
Basic Transactions
Implicit Transactions
By default, Doctrine wraps each flush() in a transaction:
$user = new User();
$user->setEmail('test@example.com');
$em->persist($user);
$em->flush(); // Auto-commits in transaction
Explicit Transactions
For multiple operations that must succeed or fail together:
<?php
// src/Service/OrderService.php
class OrderService
{
public function __construct(
private EntityManagerInterface $em,
) {}
public function createOrderWithPayment(User $user, array $items): Order
{
$this->em->beginTransaction();
try {
// Create order
$order = new Order();
$order->setCustomer($user);
$order->setStatus(OrderStatus::PENDING);
foreach ($items as $item) {
$orderItem = new OrderItem();
$orderItem->setProduct($item['product']);
$orderItem->setQuantity($item['quantity']);
$order->addItem($orderItem);
}
$this->em->persist($order);
// Create payment
$payment = new Payment();
$payment->setOrder($order);
$payment->setAmount($order->getTotal());
$this->em->persist($payment);
$this->em->flush();
$this->em->commit();
return $order;
} catch (\Exception $e) {
$this->em->rollback();
throw $e;
}
}
}
Using Transactional Helper
Cleaner approach:
public function createOrder(User $user, array $items): Order
{
return $this->em->wrapInTransaction(function () use ($user, $items) {
$order = new Order();
$order->setCustomer($user);
foreach ($items as $item) {
$order->addItem(new OrderItem($item));
}
$this->em->persist($order);
return $order;
});
}
Flush Strategies
Single Flush (Recommended)
// Good: Single flush for all changes
$user = new User();
$user->setEmail('test@example.com');
$em->persist($user);
$profile = new Profile();
$profile->setUser($user);
$em->persist($profile);
$em->flush(); // One transaction, one commit
Avoid Multiple Flushes
// Bad: Multiple flushes = multiple transactions
$user = new User();
$em->persist($user);
$em->flush(); // Transaction 1
$profile = new Profile();
$profile->setUser($user);
$em->persist($profile);
$em->flush(); // Transaction 2 - not atomic!
Flush Only When Needed
// Service layer flushes
class UserService
{
public function register(string $email): User
{
$user = new User();
$user->setEmail($email);
$this->em->persist($user);
$this->em->flush(); // Service controls transaction boundary
return $user;
}
}
// Controller doesn't flush
class UserController
{
#[Route('/register', methods: ['POST'])]
public function register(Request $request, UserService $service): Response
{
$user = $service->register($request->get('email'));
return new Response('Created', 201);
}
}
Optimistic Locking
Prevent concurrent modification conflicts:
<?php
// src/Entity/Article.php
#[ORM\Entity]
class Article
{
#[ORM\Version]
#[ORM\Column(type: 'integer')]
private int $version = 1;
public function getVersion(): int
{
return $this->version;
}
}
Usage:
use Doctrine\ORM\OptimisticLockException;
public function updateArticle(int $id, string $content, int $expectedVersion): void
{
$article = $this->em->find(Article::class, $id);
// Lock with expected version
$this->em->lock($article, LockMode::OPTIMISTIC, $expectedVersion);
$article->setContent($content);
try {
$this->em->flush();
} catch (OptimisticLockException $e) {
// Version mismatch - someone else modified it
throw new ConflictException('Article was modified by another user');
}
}
Pessimistic Locking
Lock rows in database:
use Doctrine\DBAL\LockMode;
public function processPayment(int $orderId): void
{
$this->em->beginTransaction();
try {
// Lock the row for update
$order = $this->em->find(
Order::class,
$orderId,
LockMode::PESSIMISTIC_WRITE
);
if ($order->getStatus() !== OrderStatus::PENDING) {
throw new \Exception('Order already processed');
}
$order->setStatus(OrderStatus::PROCESSING);
$this->em->flush();
$this->em->commit();
} catch (\Exception $e) {
$this->em->rollback();
throw $e;
}
}
Lock modes:
PESSIMISTIC_READ: Shared lock (SELECT ... FOR SHARE)PESSIMISTIC_WRITE: Exclusive lock (SELECT ... FOR UPDATE)
Error Handling
Connection Lost
use Doctrine\DBAL\Exception\ConnectionLost;
try {
$this->em->flush();
} catch (ConnectionLost $e) {
// Reconnect and retry
$this->em->getConnection()->connect();
$this->em->flush();
}
Constraint Violations
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
try {
$user = new User();
$user->setEmail($email);
$this->em->persist($user);
$this->em->flush();
} catch (UniqueConstraintViolationException $e) {
throw new DuplicateEmailException('Email already exists');
}
EntityManager State
After Exception
After a rollback, the EntityManager may be in an inconsistent state:
try {
$this->em->flush();
} catch (\Exception $e) {
$this->em->rollback();
// Clear the EntityManager
$this->em->clear();
// Re-fetch entities if needed
$user = $this->em->find(User::class, $userId);
}
Clearing EntityManager
// Clear all managed entities
$this->em->clear();
// Clear specific entity type
$this->em->clear(User::class);
Best Practices
- Single flush per operation: Group related changes
- Service layer transactions: Controllers don't manage transactions
- Use wrapInTransaction: Cleaner than try/catch
- Optimistic locking: For concurrent editing scenarios
- Clear after rollback: Reset EntityManager state
- Short transactions: Don't hold locks too long
// Good pattern
class OrderService
{
public function createOrder(CreateOrderDTO $dto): Order
{
return $this->em->wrapInTransaction(function () use ($dto) {
$order = new Order();
// ... build order
$this->em->persist($order);
return $order;
});
}
}