Laravel Queues Reference

A Laravel queues reference: dispatching jobs, running workers, retries and backoff, batching, and chaining.

Why queue anything

Some work is too slow to do inline — sending an email, resizing an image, calling a third-party API — and making a request wait on it is a bad trade when the user only needs an acknowledgement, not the result. A queue lets that work happen after the response has already gone back, on a separate process. It's the same instinct behind task scheduling, just answering a different question: scheduling is about when something should run, queues are about how work actually runs off-request once it's been triggered.

Choosing a queue connection

The active driver is set via QUEUE_CONNECTION in .env:

QUEUE_CONNECTION=sync
DriverBehaviour
syncRuns the job immediately, inline, in the current process — there is no actual queueing. It's the default in a fresh install and is why "my queued job isn't queueing" is usually just this setting during local development.
databaseStores jobs as rows in a table. No extra infrastructure needed — a solid default once real queueing is wanted.
redisBacked by Redis lists. Faster and more feature-complete (delayed jobs, atomic locks) than the database driver, and the usual choice in production.

Switching to the database driver needs a table to store jobs in:

php artisan queue:table
php artisan migrate

Defining a job

php artisan make:job ProcessPodcast
class ProcessPodcast implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(
        public Podcast $podcast,
    ) {}

    public function handle(AudioProcessor $processor): void
    {
        $processor->process($this->podcast);
    }
}

Implementing ShouldQueue is what makes a job queueable at all — without it, dispatching just runs handle() synchronously. Like a controller method or an Artisan command's handle(), dependencies are type-hinted and resolved from the container automatically, on top of whatever was passed into the constructor and serialised onto the job.

Dispatching

ProcessPodcast::dispatch($podcast);

// Run at least ten minutes from now
ProcessPodcast::dispatch($podcast)->delay(now()->addMinutes(10));

// Send to a specific queue name, for prioritisation
ProcessPodcast::dispatch($podcast)->onQueue('media');

// Run after the response has already been sent to the browser,
// without needing a real queue worker at all
ProcessPodcast::dispatchAfterResponse($podcast);

// Force a queueable job to run synchronously for this one call
ProcessPodcast::dispatchSync($podcast);

dispatchAfterResponse() is a middle ground between sync and a real queue: the work still happens in the same PHP process (so a worker crash can't lose it, but a PHP fatal error can), it just happens after the response bytes are already on their way to the browser — useful for something quick that doesn't justify the operational overhead of a worker.

Running a worker

php artisan queue:work
php artisan queue:listen

Both process jobs as they arrive, but they differ in one important way: queue:work boots the framework once and keeps reusing that same process for every job, which is far more efficient and is the right choice in production. queue:listen reboots the framework on every job, which is slower but means code changes are picked up immediately without restarting the worker — handy while iterating locally, since a long-running queue:work process otherwise keeps running the old code in memory until it's restarted. Like everything under php artisan, both are themselves just Artisan commands, and a custom command can dispatch jobs the same way a controller does.

Retries and backoff

class ProcessPodcast implements ShouldQueue
{
    public $tries = 3;

    public $backoff = 30; // seconds between attempts

    // or vary the delay per attempt
    public function backoff(): array
    {
        return [10, 30, 60];
    }

    public function failed(?Throwable $exception): void
    {
        // Notify someone, log extra context, etc.
    }
}

When a job exhausts its attempts, it's moved to the failed_jobs table (created by the same queue:table/migrate step, or its own queue:failed-table migration) and its failed() method runs, if defined. From there:

php artisan queue:failed          # list failed jobs
php artisan queue:retry 5         # retry one, by id
php artisan queue:retry all       # retry everything
php artisan queue:forget 5        # delete one failed job
php artisan queue:prune-failed --hours=48   # delete old failed rows, e.g. from a cron entry

php artisan queue:monitor redis:default,redis:media --max=1000 checks the given queues' size and fires a QueueBusy event past the threshold — useful as an early warning, scheduled every few minutes, that workers are falling behind before jobs start timing out or the queue grows unbounded.

Preventing duplicate or overlapping jobs

ShouldBeUnique stops the same logical job from being queued twice while an existing instance hasn't finished yet — e.g. don't queue a second "regenerate this podcast's transcript" job if one is already pending for the same podcast:

class ProcessPodcast implements ShouldQueue, ShouldBeUnique
{
    public function uniqueId(): string
    {
        return $this->podcast->id;
    }
}

Job middleware is the queue-job equivalent of HTTP middleware — a distinct concept despite the shared name, wrapping a job's execution rather than a request:

use Illuminate\Queue\Middleware\WithoutOverlapping;

public function middleware(): array
{
    return [(new WithoutOverlapping($this->podcast->id))->expireAfter(120)];
}

WithoutOverlapping prevents two instances sharing the given key from running concurrently (as opposed to ShouldBeUnique, which prevents a duplicate being queued at all) — releasing the overlapping job back onto the queue to retry shortly instead.

Batching and chaining

A chain runs a series of jobs one after another, stopping if any of them fails:

Bus::chain([
    new ProcessPodcast($podcast),
    new OptimisePodcast($podcast),
    new NotifySubscribers($podcast),
])->dispatch();

A batch runs a group of jobs with no ordering guarantee between them, but tracks overall progress and completion as a set:

$batch = Bus::batch([
    new ProcessPodcast($podcastOne),
    new ProcessPodcast($podcastTwo),
])->then(function (Batch $batch) {
    // All jobs completed successfully
})->catch(function (Batch $batch, Throwable $e) {
    // First failure
})->dispatch();

Keeping a worker running in production

php artisan queue:work is a long-running process, not a request — it needs something outside of PHP to keep it alive, restart it if it crashes, and start it again after a server reboot. A process supervisor like systemd or Supervisor is the usual tool for that job, running alongside whatever's serving the app itself; see the EC2 and Lightsail pages for the surrounding server setup. Also worth remembering: a running worker holds the app's code in memory the same way Tinker does, so a deploy needs to restart workers (php artisan queue:restart signals them to finish their current job and exit, ready for the supervisor to bring them back up on the new code) or old code keeps running until it does.

; /etc/supervisor/conf.d/queue-worker.conf
[program:queue-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/artisan queue:work --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
numprocs=2
user=www-data
stdout_logfile=/var/www/app/storage/logs/worker.log
stopwaitsecs=3600

numprocs is how many worker processes run in parallel — more processes means more jobs handled concurrently, at the cost of more memory. stopwaitsecs should exceed how long a job can realistically take, since Supervisor sends SIGTERM and then force-kills the process after that many seconds if it hasn't exited — too low a value can kill a worker mid-job.

Queued mail

Mailables commonly implement ShouldQueue too, for exactly the same reason as any other slow work — see the mail and notifications guide.