integrated/locking-bundle
A PHP package that integrates locking capabilities into your application, helping coordinate concurrent operations and protect shared resources. It provides a straightforward way to manage locks, reducing race conditions and supporting safer background jobs, commands, and workflows.
Reality check before composer require. Despite living in a "Laravel assessment," this package uses Symfony bundle conventions (-bundle suffix, DI-container-centric packaging, Doctrine event listeners). Before you spend a sprint wiring it up, open the repo and confirm three things: (1) does the main class extend Symfony\Component\HttpKernel\Bundle\Bundle? (2) is there a DependencyInjection/*Extension.php? (3) are the listeners bound to Doctrine\ORM\Event\*? If yes to all three, you are not installing a Laravel package — you are installing a Symfony package and hand-porting it. The honest first step is to read the source, not require it — treat the repo as a reference implementation of a pattern you'll reimplement natively.
Where to look first (in order):
GET_LOCK, pg_advisory_lock, SELECT ... FOR UPDATE, or a custom table. This single decision determines whether the package works at all under Octane, connection pooling, or read replicas.First use case to validate against: a single high-value back-office editor — invoice, price list, or inventory count — where two admins landing on the same record is a real, observed problem. Not content pages, not settings screens. Wire the pattern into one resource behind a feature flag, add a version column as the source of truth, and measure. If you can't name a specific entity that has already produced a lost-update ticket, this package is not your problem yet.
Minimal native equivalent (what you'll actually ship):
// app/Contracts/LockManager.php
interface LockManager {
public function acquire(string $resource, int $id, string $owner, int $ttl = 120): ?Lock;
public function heartbeat(string $resource, int $id, string $owner): bool;
public function release(string $resource, int $id, string $owner): bool;
public function holder(string $resource, int $id): ?Lock;
}
Back it with Cache::lock("{$resource}:{$id}", $ttl)->get(fn () => true) on Redis, or a resource_locks table with a unique index on (resource, id). That's ~150 lines and gives you 80% of this bundle's value with zero vendor risk.
The acquire → heartbeat → release lifecycle. Whether you fork the bundle or reimplement, the day-to-day flow is identical:
POST /locks/{type}/{id}.201 Created with an owner token, or 423 Locked with { holder: "Jane", expires_at: "..." }.PATCH /locks/{type}/{id}/heartbeat) to renew the TTL.beforeunload or explicit cancel, DELETE /locks/{type}/{id}.Layer, don't replace. Keep your optimistic version column as the source of truth. The pessimistic lock is a UX veneer on top: if the heartbeat is lost, the save should degrade to a 409 Conflict, never a silent overwrite. This means the lock check and the version check are two separate guards on the write path, and both must pass.
Owner-token pattern, not session identity. The lock owner must be a token you generate and return to the client, validated on every heartbeat and release. Session-based ownership breaks with multiple tabs (same user, two forms), and Octane/worker reuse breaks with connection or process identity.
Laravel wiring if you do adapt the bundle:
mergeConfigFrom, and bind the bundle's lock service behind your own LockManager interface — never call the vendor class directly from controllers.423 Locked (not 409) for "someone else holds it" and 409 Conflict for "version mismatch on save" — they are different UX states.Frontend heartbeat composable. Pause on document.visibilitychange (hidden tab) so background tabs don't burn requests or hold stale locks; resume and immediately re-acquire on visible. Add jitter to the interval — a network blip will otherwise cause every open editor to fire simultaneously. Use navigator.sendBeacon or fetch(..., { keepalive: true }) for the unload release; plain beforeunload AJAX is unreliable.
Scope it narrowly. Apply to the 2–3 highest-collision resources first, each behind a flag. Instrument concurrent-edit rate, orphaned-lock rate, and lost-update incidents for two weeks before widening.
Doctrine ≠ Eloquent. The description says "seamless Doctrine integration via event listeners." In Laravel, that's dead code. Any preUpdate/postUpdate hook the bundle relies on has no equivalent; you will be reimplementing enforcement in your controllers or a FormRequest. Budget for this, don't discover it mid-sprint.
Cache::lock() is not a free win. The file and array drivers are per-process — useless across app servers. You need Redis or a shared DB. get() is non-blocking and returns false immediately; block() will hang the request for the full timeout and exhaust PHP-FPM workers under contention. Use get(), fail fast, and return 423.
Octane will bite you. Long-lived workers mean any lock state held in memory, or any ownership inferred from connection/session identity, leaks across requests. Owner tokens only. Queue jobs must never hold these locks — a job that pauses for 30s holds a DB connection and blocks an editor.
Clock skew creates immortal or premature locks. Never use PHP time() for TTL math across multiple app servers. Use Redis TTL/PTTL or DB NOW() as the authoritative clock.
Authorize the heartbeat and release endpoints. The most likely defect in an unaudited lock package: any authenticated user can refresh or release another user's lock by guessing {type}/{id}. Verify owner token on every mutation, and rate-limit the heartbeat route.
Orphaned locks need a reaper. Tab closed without release → row lives until TTL. Short TTL (60–120s) plus a scheduled locks:reap command. Also decide now whether force-unlock is support-only (with audit log) or user-facing — this is a product decision, not a coding one.
Fail-open vs. fail-closed must be explicit per resource. If Redis is down, do invoices block editing (fail-closed, data integrity wins) or allow it (fail-open, availability wins)? Decide per entity and encode it in the service, not in scattered catch blocks.
Read replicas can lie. holder() queries against a replica will return stale "unlocked" state right after someone acquires. Route lock reads to the primary connection.
Multi-tab same user. The same user in two tabs will lock themselves out. Decide whether to allow multiple owner tokens per user per resource, or surface "you have this open in another tab."
Maintenance reality. 0 stars, 0 dependents, no changelog, no CI. If you take the dependency, pin the exact version and vendor the source — you are the maintainer. Budget ~2–4 days/quarter for a native implementation; the real cost is the behavioral change ("you can't edit this right now") and the conflict-UX that comes with it.
Test the hard cases explicitly: two browsers on the same record, two app servers behind a load balancer, a hard crash with no release, and a heartbeat failure mid-edit. If the save silently overwrites in the last case, the implementation is worse than no lock at all.
How can I help you explore Laravel packages today?