Ensure your Laravel queue jobs run on only one server instance at a time using distributed locking. Includes automatic crash recovery, lock heartbeat, and supports both Database and Redis drivers.
When running multiple queue workers across different servers, sometimes you have jobs that must not run concurrently under any circumstances (e.g., end-of-day financial calculations, syncing large datasets with third-party APIs).
While Laravel's WithoutOverlapping middleware is great, if a server crashes midway through a job, the lock gets permanently stuck until manually cleared.
queue-unique-runner provides robust distributed locking with:
- Heartbeat mechanism: Periodically extends the lock while the job is actively running.
- Crash recovery: If a server crashes or the worker is abruptly killed, the heartbeat stops, the lock expires automatically via TTL, and another server can safely retry the job.
- Per-class or Per-instance locking: Lock the entire job class, or lock per unique payload.
You can install the package via composer:
composer require bytetcore/queue-unique-runnerIf using the Database driver (the default), publish and run the migrations:
php artisan vendor:publish --tag="queue-unique-runner-migrations"
php artisan migrateOptionally, publish the config file:
php artisan vendor:publish --tag="queue-unique-runner-config"Simply add the RunsOnUniqueRunner trait to your job:
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Bytetcore\QueueUniqueRunner\Traits\RunsOnUniqueRunner;
class ProcessFinancialAudit implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
use RunsOnUniqueRunner; // <-- Add this trait
public function handle(): void
{
// This code is guaranteed to only run on one server at a time.
// If a server crashes here, the lock automatically expires.
}
}You can override the default configuration for specific jobs by overriding these methods:
class SyncUserData implements ShouldQueue
{
use RunsOnUniqueRunner;
public int $userId;
public function __construct(int $userId)
{
$this->userId = $userId;
}
// Lock scope: 'class' (only one SyncUserData job anywhere)
// or 'instance' (one SyncUserData job per unique payload)
public function queueUniqueRunnerScope(): string
{
return 'instance';
}
// Custom identifier for 'instance' scope
public function queueUniqueRunnerIdentifier(): ?string
{
return 'user:' . $this->userId;
}
// How long the lock should be held (in seconds)
public function queueUniqueRunnerTtl(): int
{
return 600; // 10 minutes
}
// How long to wait before retrying if another server holds the lock
public function queueUniqueRunnerRetryDelay(): int
{
return 60; // Wait 60 seconds
}
}Creates a queue_unique_runner_locks table. Uses unique constraints to guarantee atomic locks.
It is recommended to periodically run the prune command to clean up expired locks from the database:
# Add this to your Console/Kernel.php schedule
$schedule->command('queue-unique-runner:prune')->daily();Uses SET NX EX commands and Lua scripts for atomic operations. Extremely fast and automatically handles expired lock cleanup.
Change your .env:
SINGLE_JOB_DRIVER=redis
SINGLE_JOB_REDIS_CONNECTION=default- PHP 8.0+
- Laravel 9.0+
- (Optional but recommended)
pcntlextension for Heartbeat functionality
composer test