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

Laravel Unique Job Locks Laravel Package

mohannadelemary/laravel-unique-job-locks

Prevent duplicate Laravel queue jobs from running at the same time. This package adds unique locks to dispatched jobs so identical tasks are skipped or deduplicated until the first one finishes. Simple setup, configurable lock keys and TTLs, and support for Redis and other cache stores.

View on GitHub
Deep Wiki
Context7

Getting Started

Requirements first, because they decide whether the tool is even usable in your app: PHP 8.3+, Laravel 12 or 13, and a Redis cache store (phpredis or predis). Database/file/array stores are not supported yet — if your ShouldBeUnique job locks via uniqueVia() into a database store, both commands will simply find nothing.

composer require mohannadelemary/laravel-unique-job-locks
php artisan list | grep unique-lock

Commands are auto-registered; there is nothing to publish or configure. Confirm the prerequisite that actually matters before trusting any output: the job's lock store must be Redis.

class SyncInventory implements ShouldBeUnique
{
    public function uniqueVia(): Repository
    {
        return Cache::store('redis'); // must be a Redis LockProvider
    }
}

First use case — the one you'll hit in week one: a unique job silently stops dispatching after queue:clear, a deploy, or a kill -9'd worker. Run the explanation command rather than guessing:

php artisan queue:unique-lock:why "App\Jobs\SyncInventory"

It prints the store (honouring uniqueVia()), the lock connection, the full Redis key with cache + connection prefixes, and a verdict: still queued, worker running it, worker probably crashed, or orphaned. Start here; only reach for --release after you agree with the verdict. Both commands are read-only unless you pass --release, which prompts — safe to run against production from day one.

queue:unique-locks is the inventory view: job, unique ID, TTL left, owner. Use it for "what's holding everything up right now".

Implementation Patterns

The triage loop. The release notes imply a workflow; codify it as muscle memory:

  1. queue:unique-locks — is a lock even there, and what's its TTL?
  2. queue:unique-lock:why {job} --id= — read the verdict and the key.
  3. Act on the verdict, not the symptom: still queued → scale workers / check the queue connection; worker running → wait; crashed → wait out --timeout + retry_after before touching anything; orphaned → --release.

Filter before you scan. On a busy Redis cache, always narrow the listing:

php artisan queue:unique-locks --job="App\\Jobs\\SyncInventory"
php artisan queue:unique-lock:why "App\\Jobs\\SyncInventory" --id=customer-42 --limit=2000

Post-deploy / post-maintenance runbook. After queue:clear, queue:flush, horizon:terminate mid-job, or a supervisor restart, lock leaks are expected. Add the read-only command to the deploy checklist for staging, and to the incident runbook for production.

Scheduled orphan detection. Locks with no uniqueFor() never expire, so an orphan is permanent. Poll for them instead of discovering them from a stuck feature:

// routes/console.php
Schedule::command('queue:unique-locks')
    ->hourly()
    ->environments(['production'])
    ->onOneServer();

Parse the output (or a --job=-filtered run for a business-critical job) and alert when a lock has no plausible TTL or has outlived its expected queue latency. Pair it with queue:monitor so you can tell "lock is fine, queue is backed up" from "lock is a ghost".

Reproduce locally. The fastest way to understand ShouldBeUnique semantics is to break them on purpose: dispatch a unique job, queue:clear, then run queue:unique-lock:why. You now have an orphaned lock and a queue with no job — the exact production failure, in ten seconds. Do this once; it calibrates everything you read afterwards.

Verify the key by hand. The command's value is that it prints the exact Redis key. Cross-check with the CLI so you learn how prefixes compose:

redis-cli -n 0 --scan --pattern '*laravel_unique_job*'
redis-cli -n 0 TTL "laravel_database_laravel_unique_job:App\\Jobs\\SyncInventory:42"
redis-cli -n 0 GET "laravel_database_laravel_unique_job:App\\Jobs\\SyncInventory:42" # owner token

That GET value is the owner token the command compares against before --release removes anything.

Verify uniqueVia() without dispatching. Because the command reports store and connection, it's the cheapest way to confirm which of your several cache stores a job actually locks in. Useful when CACHE_STORE differs between local, staging and production and the job has no explicit uniqueVia().

In tests and CI. Use a real Redis service in CI rather than the array store, and drive the commands:

$this->artisan('queue:unique-locks')->assertSuccessful();
$this->artisan('queue:unique-lock:why', ['job' => SyncInventory::class])
    ->expectsConfirmation('Release this lock?', 'no')
    ->assertSuccessful();

Under --no-interaction the confirmation defaults to "no", so a stray --release in a pipeline is inert. That's a good property — lean on it rather than scripting yes.

Gotchas and Tips

--release is not a general fix. If the verdict is "still queued" or "worker running it", removing the lock lets a second job dispatch and you get the concurrent execution the lock existed to prevent. Release only on "orphaned", and only after confirming the job isn't sitting in a queue the search didn't cover.

--limit can manufacture a false "orphaned". The verdict depends on reading the queue to find a matching job. If the queue is deep and your limit is low, a queued job falls outside the read window and the tool will report orphaned. Raise --limit (or drain the queue) before releasing anything you're not certain about.

The search is per connection and per queue. Jobs live in {queue} (list), {queue}:delayed (ZSET) and {queue}:reserved (ZSET). A job dispatched across multiple queues, on a non-default connection, on a different Redis DB, or on a chained/batched dispatch may not be where the search looks. "Worker is running it" vs "worker probably crashed" is inferred from the reserved set and its reserved_at — if that inference is wrong for your setup, the verdict is too.

Infinite TTL is the default and it's a trap. Without uniqueFor(), the lock has no expiry — a crashed worker or an orphaned lock lives until someone releases it or the cache is flushed. "TTL left" showing no expiry is your signal to add uniqueFor() to the job so leaks self-heal.

ShouldBeUniqueUntilProcessing vs ShouldBeUnique changes what a lock means. The former releases the lock the moment a worker picks the job up, so seeing the lock plus a reserved job is normal, and releasing it is nearly harmless. The latter holds it through completion — releasing it is genuinely dangerous while the worker runs. Know which interface each job implements before acting.

Locks leak on failure paths, not just crashes. A job that throws, exhausts $tries, or is force-failed by a worker can leave the lock behind until TTL; behaviour varies by Laravel version and by whether the job is deleted or released for retry. Treat "job failed yesterday, now it won't dispatch" as a lock leak first, code bug second.

Transaction rollbacks orphan locks. A unique job dispatched inside DB::transaction acquires the lock immediately; if the transaction rolls back, the job is never queued but the lock remains. Fix at the source with afterCommit / ShouldDispatchAfterCommit; clean up with --release.

Renaming a job class or changing uniqueId() invisibly orphans every existing lock. The key contains the class name and the unique ID, so the new code looks at a different key while the old lock sits there forever. Same for rotating CACHE_PREFIX or config('cache.prefix') — the app can no longer see its own locks. The command's key output is how you find the strays.

Shared Redis across apps or stages. If staging and production (or two apps) point at the same Redis DB with the same prefix, they share locks. Read the printed prefix carefully and set distinct prefixes per environment.

--release is a write against a live cache with no --dry-run and no --force. The owner-token check protects you from deleting a lock someone else just re-acquired, but it does not stop a racing dispatch from immediately re-taking it after release. Do it in a quiet window, and run the read-only form first.

Redis Cluster and ACLs are unverified assumptions. Key scanning across shards and Lua-based release in cluster mode both have edge cases; likewise a restricted ACL user needs SCAN, GET, TTL and DEL. Test on a staging cluster before relying on it in production, and prefer --job= filters to keep the scan cheap — an unfiltered run against a cache with millions of keys is the kind of thing you should not do at peak.

queue:unique-locks is not a general lock inspector. It surfaces Laravel's unique-job locks (laravel_unique_job: keys); unrelated Cache::lock() entries are out of scope, so don't expect it to explain a stuck mutex elsewhere.

0.x maturity. Pin the version, expect command names, column output and flags to shift, and check the changelog on upgrade — anything you parse in a scheduled health check is a moving target. Database-store support is announced as planned, which will likely change the store-detection behaviour and the meaning of "no lock found" (currently, that often means "you're on the wrong store").

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.
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox