tourze/doctrine-entity-lock-bundle
A Symfony bundle that provides entity locking for Doctrine ORM, preventing concurrent modifications by locking records during critical operations. Integrates through Doctrine events and bundle configuration for safer transactional workflows.
tourze/doctrine-entity-lock-bundle is a Symfony bundle that layers entity-level locking helpers on top of Doctrine ORM — optimistic concurrency (version columns) and pessimistic FOR UPDATE-style row locks, plus the plumbing (Lockable contract, lock handler/manager services, Doctrine event subscribers) that the wider Tourze stack assumes. As a Laravel dev you almost certainly are not using this directly — you're either:
laravel-doctrine) and want the lock helpers without the bundle.Minimal steps:
composer require tourze/doctrine-entity-lock-bundle:^1.0
src/ first, not the README. The README is thin; the useful surface is the *Bundle class (what gets registered), the DependencyInjection/ extension (what services/aliases are defined), and the Doctrine event subscribers (where locking is actually applied). That tells you the real integration points in ~10 minutes.Model::lockForUpdate() equivalent being added. Eloquent's ->lockForUpdate() / ->sharedLock() already covers the pessimistic case.Laravel wiring (no bundle autodiscovery):
// app/Providers/DoctrineLockServiceProvider.php
public function register(): void
{
$this->app->singleton(LockManager::class, fn () => new LockManager(/* deps from src/ */));
}
Optimistic locking (preferred default). Add a version field to the entity and let Doctrine bump it on flush; a concurrent writer causes a version mismatch. This is the cheapest pattern and the one you want for user-facing edit forms.
$em->wrapInTransaction(function () use ($em, $id) {
$entity = $em->find(Invoice::class, $id);
$entity->setAmount($newAmount);
$em->flush(); // throws OptimisticLockException on conflict
});
Wrap callers in a bounded retry (3 attempts, jittered backoff) and surface a friendly "someone else changed this" 409 rather than a 500.
Pessimistic locking. Inside a transaction, lock the entity before reading mutable state:
$em->wrapInTransaction(function () use ($em, $sku) {
$stock = $em->getRepository(Stock::class)->findOneBy(['sku' => $sku]);
$em->lock($stock, LockMode::PESSIMISTIC_WRITE);
$stock->decrement($qty);
});
Workflow tips
StockService::decrement()), not in controllers. Lock scope should match transaction scope exactly.try/catch around every flush.handle() transaction. Never carry a locked entity across job boundaries or into an HTTP response.new the manager at call sites.SELECT ... FOR UPDATE, so pessimistic tests pass vacuously.config/bundles.php, no config/packages/*.yaml. You must either (a) register the classes manually in a service provider and skip the bundle class, or (b) use laravel-doctrine and wire the extension by hand. Expect to read the DI extension to know which aliases matter.wrapInTransaction inside DB::transaction) create savepoints, not real commits. Locks acquired inside a savepoint are held until the outermost commit — easy way to hold locks far longer than intended.innodb_lock_wait_timeout (or lock_timeout on Postgres) deliberately and alert on lock-wait spikes.SELECTs and can deadlock if the order differs between code paths. Lock the owning aggregate root only; avoid touching associations while holding a write lock.FOR UPDATE / version-incrementing UPDATEs. If you don't see them, your lock isn't being applied — usually a missing flush, wrong entity instance (detached), or the lock call sitting outside the transaction.vendor/.1.0.1 exactly, diff 1.0.0...1.0.1 on GitHub before upgrading in anything that touches money or inventory, and re-run your concurrency tests (parallel writers against the same row) rather than relying on semver.How can I help you explore Laravel packages today?