Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Doctrine Datalocking Bundle Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

Implementation Patterns

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:

  • Scheduler dispatch: the cron command locks and dispatches one message per row (or one per batch, carrying the $lockId). Handlers that fail and retry should resume from the lock ID rather than re-locking.
  • Handler owns the batch: the message handler calls 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.

Gotchas and Tips

  • Maintenance risk is the headline. Last release 2022-05-25, two stars, no dependents. Check 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.
  • Portability of the claim query. "UPDATE … LIMIT" is MySQL syntax; Postgres needs 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.
  • Do not hold a DB transaction open across processing. Acquire the lock in its own transaction so the ID is visible to the processor, commit, then process. A long transaction defeats the purpose and risks lock-wait timeouts.
  • 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.
  • Release in 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.
  • Column type matters. Use a 36-char string or a 16-byte binary UUID consistently; a mismatch between the bundle's setLockId() expectation and your mapping produces silent no-op locks.
  • SQLite is unsupported in spirit. Row-level claim semantics need real concurrency. If your dev environment is SQLite, keep a MySQL/Postgres service for integration tests.
  • Check what the bundle auto-registers. Look in 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.
  • Know when to skip it. If you only need mutual exclusion (one worker at a time), Symfony's Lock component or a 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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
coleus/frame
thtg88/journalism
filagora/filagora-ai-forums
rlacerda83/query-parser
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings