dualmedia/doctrine-retry-bundle
Symfony bundle that wraps Doctrine transactions with automatic retries for deadlocks and transient DB errors. Configure optional nesting tracking, then call Retrier->execute() with a closure receiving the EntityManager to safely run retryable work.
Installation:
composer require dualmedia/doctrine-retry-bundle
Register the bundle in config/bundles.php:
DualMedia\DoctrineRetryBundle\DoctrineRetryBundle::class => ['all' => true],
First Use Case:
Inject Retrier into a service and wrap database operations that may fail due to deadlocks or contention:
use DualMedia\DoctrineRetryBundle\Retrier;
use Doctrine\ORM\EntityManagerInterface;
class OrderProcessor
{
public function __construct(private Retrier $retrier) {}
public function processOrder(int $orderId): void
{
$this->retrier->execute(function (EntityManagerInterface $em) use ($orderId) {
$order = $em->getRepository(Order::class)->find($orderId, LockMode::PESSIMISTIC_WRITE);
// Business logic here
$em->flush();
});
}
}
config/packages/dm_doctrine_retry.yaml:
dm_doctrine_retry:
track_nesting: '%kernel.debug%' # Warns about nested transactions in debug mode
Retryable Operations:
Use Retrier::execute() for any operation that might fail due to:
Locking Strategies:
Combine with Doctrine lock modes (e.g., PESSIMISTIC_WRITE) for atomicity:
$entity = $em->find(Entity::class, $id, LockMode::PESSIMISTIC_WRITE);
Event-Driven Extensions:
Listen to TransactionStartEvent for pre-transaction hooks:
$eventDispatcher->addListener(TransactionStartEvent::class, function (TransactionStartEvent $event) {
// Log or pre-process before retry
});
Dependency Injection:
Prefer constructor injection of Retrier over manual instantiation.
// services.yaml
services:
App\Services\OrderProcessor:
arguments:
$retrier: '@dm_doctrine_retry.retrier'
Command Bus Integration: Wrap retryable logic in commands:
$bus->handle(new ProcessOrderCommand($orderId));
Testing:
Mock Retrier to simulate retries:
$this->mockRetrier->shouldReceive('execute')->once()->andReturnUsing(function ($callback) {
$callback($this->mockEm);
});
Nested Transactions:
Retrier::execute() calls. Enable track_nesting in debug mode to catch this:
dm_doctrine_retry:
track_nesting: true
Retrier wrapper for the entire operation.Stateful Operations:
Event Listeners:
TransactionStartEvent fire per retry, not just once.Performance Overhead:
$this->retrier->execute(function (EntityManagerInterface $em) {
$this->logger->info('Retry attempt', ['attempt' => $this->retrier->getAttemptCount()]);
// ...
});
track_nesting: true to log nested transaction warnings.$retrier->getAttemptCount() inside the callback to debug retry loops.TransactionEvent for low-level debugging:
$eventDispatcher->addListener(TransactionEvent::class, function (TransactionEvent $event) {
if ($event->isRolledBack()) {
$this->logger->error('Transaction rolled back', ['exception' => $event->getException()]);
}
});
Custom Retry Logic:
Extend Retrier by implementing RetryStrategyInterface:
class CustomRetryStrategy implements RetryStrategyInterface
{
public function shouldRetry(Throwable $exception, int $attempt): bool
{
return $exception instanceof DeadlockException && $attempt < 3;
}
}
Register it in services.yaml:
services:
App\Retry\CustomRetryStrategy:
tags: { name: 'dm_doctrine_retry.strategy' }
Global Configuration: Override default retry limits via DI:
services:
dm_doctrine_retry.retrier:
arguments:
$maxAttempts: 5
$retryStrategy: '@App\Retry\CustomRetryStrategy'
Transaction Isolation:
Combine with Doctrine’s Connection::beginTransaction() for fine-grained control:
$this->retrier->execute(function (EntityManagerInterface $em) {
$conn = $em->getConnection();
$conn->beginTransaction();
// Custom isolation level
$conn->setTransactionIsolation(Connection::TRANSACTION_READ_COMMITTED);
// ...
});
How can I help you explore Laravel packages today?