Handle database transactions with Doctrine UnitOfWork; implement optimistic locking, flush strategies, and transaction boundaries
This skill inherits all available tools. When active, it can use any tool Claude has access to.
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
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;
}
}
}
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;
});
}
// 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
// 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!
// 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);
}
}
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');
}
}
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)use Doctrine\DBAL\Exception\ConnectionLost;
try {
$this->em->flush();
} catch (ConnectionLost $e) {
// Reconnect and retry
$this->em->getConnection()->connect();
$this->em->flush();
}
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');
}
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);
}
// Clear all managed entities
$this->em->clear();
// Clear specific entity type
$this->em->clear(User::class);
// 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;
});
}
}