webonaute/doctrine-datalocking-bundle
A Symfony bundle that brings data locking to Doctrine entities, helping prevent concurrent modification conflicts and keeping persistence safe with minimal configuration.
This is a very small, low-traffic bundle (2 stars, 0 dependents, last release May 2022). Treat it as a reference implementation of a pattern as much as a dependency: before you build on it, spend 20 minutes reading every file in src/. It likely contains one service, one interface, and maybe a trait — that is the whole API surface. If its constraints don't match your stack, reimplementing the same pattern is a day's work.
Install and register
composer require webonaute/doctrine-datalocking-bundle
// config/bundles.php
return [
// ...
Webonaute\DoctrineDataLockingBundle\WebonauteDoctrineDataLockingBundle::class => ['all' => true],
];
First stop: the lockable entity contract. The bundle needs to know which column holds the lock owner (a UUID-style lock ID) and usually a timestamp so stale locks can expire. Look for an interface or trait in src/ (something like LockableInterface/LockableTrait) and apply it to your entity — or add the fields yourself if the trait doesn't fit your mapping style:
#[ORM\Entity]
class Order
{
#[ORM\Column(type: 'string', length: 36, nullable: true)]
#[ORM\Index(columns: ['lock_id'])] // required: every lookup filters on this
private ?string $lockId = null;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $lockedAt = null;
// getters/setters as required by the bundle's interface
}
Run bin/console doctrine:migrations:diff and migrate. Verify the column is indexed — the whole pattern is "UPDATE rows to claim them, then SELECT by lock ID," and both sides scan that column.
First use case: a cron/Messenger worker that must process each row exactly once across N concurrent workers — exporting orders, sending notifications, syncing products to an ERP. Worker A calls lock(Order::class, ['status' => 'pending'], limit: 100, ttl: 900), receives a UUID, fetches only the rows carrying that UUID, does the work, and releases. Worker B calls lock() at the same moment and gets either a different set of rows or null. That is the entire value proposition.
The canonical acquire → process → release loop. Always wrap processing in try/finally; a crashed worker must not leave rows permanently claimed (TTL is your second line of defence, not your first):
$entityClass = Order::class;
$lockId = $this->locking->lock($entityClass, ['status' => 'pending'], limit: 100, ttl: 900);
if ($lockId === null) {
return Command::SUCCESS; // nothing to claim
}
try {
// Re-fetch by lock ID; don't trust an array captured before the lock.
foreach ($this->locking->getLockedEntities($entityClass, $lockId) as $order) {
$this->handler->handle($order);
}
} finally {
$this->locking->release($entityClass, $lockId);
}
Drain in a loop when the backlog exceeds the batch size. Locking 100 rows out of 50,000 means the command must loop until lock() returns null. Add an iteration cap so a misbehaving producer can't make a single cron run forever:
while ($lockId = $this->locking->lock($entityClass, $criteria, 100, 900)) {
try { /* process */ } finally { $this->locking->release($entityClass, $lockId); }
if (++$batches >= 50) { break; }
}
Scope the criteria, don't filter after fetching. Pass the WHERE conditions into lock() so you claim precisely the rows you intend to process (tenant ID, status, createdAt < now - 5 min). Post-fetch filtering wastes claims and can strand rows for the TTL duration.
Messenger integration. Two viable shapes:
$lockId). Handlers that fail and retry should resume from the lock ID rather than re-locking.lock(), processes, releases. Use a short TTL (e.g. 2× the handler timeout) so a poisoned message doesn't freeze the rows for an hour.TTL as a crash-recovery SLA, not a business rule. The claim query should select rows where lock_id IS NULL OR locked_at < :now - ttl. Set ttl comfortably above your p99 processing time. A short TTL with long processing = two workers on the same row; that's the failure mode to design against.
Observability. Log the lock ID at acquisition and the row count at release. Emit a metric for rows whose lockedAt is older than TTL — a rising number means workers are crashing or timing out silently. A small app:locking:release-stale command that clears expired locks manually is worth the ten lines.
Testing. Write an integration test against a real MySQL/Postgres instance that runs two lock acquisitions back-to-back and asserts the second returns null or a disjoint set. In-memory SQLite will pass this test and prove nothing.
composer.json constraints before committing: it almost certainly does not declare Doctrine ORM 3 / DBAL 4 / Symfony 7 support. Pin the version and be prepared to fork; composer why-not will tell you quickly.WHERE id IN (SELECT id … FOR UPDATE SKIP LOCKED). If the bundle does SELECT-then-UPDATE rather than an atomic claim, two workers can grab the same rows under load. Read the implementation; if it's not atomic, either add a unique/conditional constraint or replace the claim with SKIP LOCKED.EntityManager::clear() mid-batch invalidates your locked entities. If your handler clears the EM for memory, re-fetch by lock ID afterwards rather than reusing detached objects.finally, and decide your partial-failure semantics deliberately. Release everything (failed rows get retried on the next run) or release only successes (failed rows stay claimed until TTL). Pick one and document it — silently mixing the two is the most common bug in this pattern.setLockId() expectation and your mapping produces silent no-op locks.src/Resources/config/ for service definitions and any bundle configuration tree (webonaute_data_locking: keys for table/column names or default TTL). If there is no config tree, the service is probably autowired by class name — grab it from the container with bin/console debug:container | grep -i lock to find the real service ID before guessing.symfony/messenger transport already does this. This bundle is specifically for row ownership — one processor per set of rows — and if that's not your problem, the dependency buys you nothing.How can I help you explore Laravel packages today?