Dispatch Locks vs Worker Locks
When building background processing pipelines in Laravel 12, duplicate job dispatching is one of the most annoying bugs you'll face. Someone double-clicks a button in your React 19 frontend, or an upstream webhook fires three identical HTTP POST requests within 20 milliseconds. If your job updates a database record, sends an email, or processes a payment, running it twice causes real damage.
Laravel gives you two primary mechanisms to stop this: the ShouldBeUnique interface and the WithoutOverlapping queue middleware. They sound similar, but they operate at completely different points in the job lifecycle. Mixing them up leads to lingering locks, silent job drops, or workers spinning infinitely while releasing jobs back into Redis.
ShouldBeUnique acts as a dispatch guard. It uses an atomic lock—usually backed by Redis or Memcached—to block duplicate jobs from ever reaching the queue. If a matching lock already exists when you call ProcessReport::dispatch(), Laravel quietly drops the dispatch attempt or throws an exception if configured. The second job never touches the queue database or Redis list.
WithoutOverlapping, on the other hand, is worker middleware. It permits any number of duplicate jobs to land in the queue. The restriction happens when worker processes pull those jobs off the stack. Before executing handle(), the worker acquires an execution lock. If another worker is already processing a job with the same key, the second worker holds or releases the job back to the queue with a delay.
Preventing Duplicate Queueing with ShouldBeUnique
Use ShouldBeUnique when queuing duplicate work is entirely wasteful. Generating a monthly PDF report for user 402 twice in ten seconds is pointlessly burning CPU cycles. You want the second dispatch attempt to fail immediately at the controller level.
Here is how you implement it in PHP 8.3:
namespace App\Jobs;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class GenerateMonthlyReport implements ShouldQueue, ShouldBeUnique
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $uniqueFor = 300;
public function __construct(public User $user) {}
public function uniqueId(): string
{
return (string) $this->user->id;
}
}In this class, $uniqueFor = 300 tells Laravel to hold the lock for 5 minutes. The lock key is derived from the class name and the string returned by uniqueId(). In Redis, the key looks like laravel_unique_job:App\Jobs\GenerateMonthlyReport:402.
By default, Laravel acquires this lock when the job is dispatched and releases it automatically when the job finishes executing. If your report takes 45 seconds to generate, any subsequent dispatch calls during those 45 seconds return false immediately. If the job completes in 45 seconds, the lock is freed early unless you explicitly instruct Laravel otherwise.
Releasing Locks Before Execution Starts
Sometimes you want to prevent duplicate queueing, but allow a new job to be queued as soon as the current one starts running—rather than waiting for it to finish. Switch to ShouldBeUniqueUntilProcessing:
When using ShouldBeUniqueUntilProcessing, Laravel drops the lock the microsecond a worker picks up the job and begins executing handle(). This allows a fresh request from the frontend to queue the next report while the first one processes.
Managing Runtime Concurrency with WithoutOverlapping
There are scenarios where every incoming payload must run, but no two workers should process payloads for the same entity at the exact same moment. Think about Stripe webhooks updating an account balance. You cannot drop incoming webhook dispatches because every payload carries distinct balance mutations. However, running two balance updates concurrently for account 99 creates severe race conditions.
This is where WithoutOverlapping shines. It moves the lock mechanism into the job's middleware() method:
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
class ProcessAccountWebhook implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public string $accountId,
public array $payload
) {}
public function middleware(): array
{
return [
(new WithoutOverlapping($this->accountId))
->releaseAfter(15)
->expireAfter(180)
];
}
public function handle(): void
{
// Perform account state updates safely
}
}Notice the two method calls on the middleware: releaseAfter(15) and expireAfter(180). These settings control job retry behavior and failure safety.
releaseAfter(15) tells the worker: if another worker is currently holding the lock for account ID 99, don't throw an error. Instead, release this job back to the queue and wait 15 seconds before picking it up again. If you call dontRelease() instead, Laravel marks the job as failed immediately.
expireAfter(180) is your insurance policy against crashed workers. If a worker process runs out of memory or gets killed by a SIGKILL signal while processing account 99, it won't execute destructors or unlock keys. Without an expiration, that account ID remains locked forever. Setting expireAfter(180) forces Redis to purge the lock key after 3 minutes no matter what happens.
The Production Gotchas That Will Bite You
Implementing queue locks looks clean in code reviews, but production environments expose several edge cases that break systems quietly.
Cache Store Mismatches in Multi-Server Deployments
Laravel's unique job locking uses your default cache store unless specified. If your environment config sets CACHE_STORE=file or CACHE_STORE=array while you run three background worker nodes, atomic locks only work locally on each worker machine. Server A will have no idea that Server B is running a job for user 402.
You must use a distributed store like Redis or Memcached. You can explicitly set the lock store inside your job class by defining a uniqueVia() method:
public function uniqueVia()
{
return Cache::store('redis');
}For WithoutOverlapping, pass the shared store instance directly or configure Redis as the default cache driver across all environment configurations.
The Thundering Herd Queue Flood
Using WithoutOverlapping with low releaseAfter() values can accidentally overload your queue workers. Imagine 2,000 webhooks arriving at once for the same active user account. Worker 1 takes job 1 and locks the account ID.
The remaining 1,999 jobs get picked up by other workers, fail to acquire the lock, and get released back to Redis with a 3-second delay. Three seconds later, 1,999 jobs become available again simultaneously. Your queue workers spend 95% of their CPU time picking up locked jobs, failing lock acquisition, and re-writing them to Redis.
To fix this, introduce exponential backoff or use dontRelease() combined with job retries and backoff strategies on the job class itself:
public int $tries = 5;
public array $backoff = [10, 30, 60, 120];Database Transaction Isolation Hazards
A frequent bug occurs when jobs dispatch inside a database transaction before that transaction commits. A controller starts a database transaction, dispatches a job with ShouldBeUnique, and then performs a slow database write.
If a fast worker picks up the job instantly, it searches the database for a record that hasn't been committed by the web request yet. The job fails with a ModelNotFoundException. Always set AFTER_COMMIT queue settings or use DB::afterCommit() inside your controllers before dispatching locked jobs.
Which Approach Should You Pick?
Default to ShouldBeUnique when duplicate dispatches represent redundant side-effects. User export triggers, daily digest emails, and search re-indexing jobs belong here. It saves queue storage and reduces total system load.
Reach for WithoutOverlapping when every payload contains critical state changes that must be processed sequentially. Financial ledgers, order state machines, and inventory sync operations require runtime mutual exclusion.













