Introduction
Queues are the secret engine behind many apps. They can turn slow, fragile tasks into fast, reliable workflows. Whether you’re sending thousands of emails, processing videos, or orchestrating complex pipelines.
This article goes behind the curtains on how Laravel’s queue system works. From the moment you dispatch a job, to how it’s serialized, stored, popped, executed and retried. We’ll trace the full lifecycle of a message, looking into workers, visibility timeouts, backoff, chains and batches.
Get a coffee and prepare to really understand how queues work in Laravel (and in general).
Why Queues Matter
Queues keep your app fast and reliable. By moving slow or risky work off the request cycle, Laravel lets you return responses quickly, absorb traffic spikes, run tasks in parallel and even retry them in a safe way if something goes wrong. They decouple responsibilities (web -> workers), isolate failures, and give you control over timing, and priority.
Common wins
-
Offload I/O-heavy tasks: emails, webhooks, PDFs, image/video processing, AI/API calls.
-
Improve reliability: retries with backoff and timeouts.
-
Scale predictably: add workers without changing business code.
Core Concepts
Before diving into how Queues work in Laravel, let's learn some concepts that we will be using in this article that can help make things simpler.
-
Job: A small class (
ShouldQueue
) whosehandle()
does the work. -
Dispatch: Sends a job to a connection with metadata (queue, delay, attempts).
-
Connection: The backend you use (Redis, Database, SQS), chosen by name.
-
Queue (name): A lane within a connection for priorities (high, default, low).
-
Driver/Connector: The implementation that pushes/pops jobs on the connection.
-
Payload & serialization: JSON envelope where models serialize to IDs and closures via
SerializableClosure
. -
Worker:
php artisan queue:work
, a long-running process that pops, runs middleware, and acknowledges jobs. -
Visibility timeout: The “lock” window. It must exceed job runtime to avoid duplicates.
-
Acknowledgement: Delete on success. Release to retry later. Bury/fail after limits.
-
Retries & backoff: Control how often and how long to wait between attempts.
-
Failures: Failed jobs land in
failed_jobs
. -
Middleware: Cross-cutting rules like throttling and rate limiting.
-
Chains & batches: Sequential flows and grouped jobs with progress/failure rules.
-
Idempotency: Safe to run twice. Use unique keys, upserts, and guards.
High-Level Architecture
If we look it in a really simplified way, Laravel's Queue System is a simple pipeline: create a job, serialize it, store it, pop it, run it, and acknowledge it. All of it, having safety mechanisms for retries and failures.
Dispatch
You call Job::dispatch(...)
. The Bus serializes the job (class + data + metadata) and hands
it to the Queue Manager.
Connector
The Queue Manager picks a connector (Redis, Database, SQS) and pushes a JSON payload onto the chosen queue (name/lane).
Worker
The php artisan queue:work
runs as a long‑lived process. It reserves a job, locks it
(visibility timeout), and resolves dependencies from the container.
Middlewares
Throttling, retries, rate limits, etc., wrap the Jobs handle()
method.
Acknowledge or retry
On success, deletes the job. Exceptions trigger release (retry with backoff) or mark as failed after maxTries
.
Example
class ProcessReport implements ShouldQueue
{
use Queueable, SerializesModels;
public int $timeout = 30;
public function __construct(public Report $report) {}
public function backoff(): array
{
return [5, 30, 90];
}
public function handle(): void
{
// Process report logic here
}
}
// Dispatch after DB commit, on a high-priority queue
ProcessReport::dispatch($report)
->afterCommit()
->onQueue('high');
# Running the worker
php artisan queue:work --queue=high,default --timeout=35 --max-jobs=1000 --max-time=3600
Dispatch Lifecycle
Before diving a little deeper on how Laravel handles the dispatch logic. Let's check a high-overview on how the job goes from your code to the queue connection.
- You dispatch the job:
Job::dispatch(...)
and Laravel's Bus collects the options (queue, connection, delay, etc). - The job is serialized (we're going to check how this works in the next section).
- The Queue Manager picks a connection (Redis, Database, SQS) and queue name, uses a connector to push the payload, respecting delay/visibility settings.
Now that we have an idea on how it works, let's dive a little more on what happens when you
call Job::dispatch(...)
.
The Dispatchable
trait's dispatch()
method returns a PendingDispatch
wrapper around
your job and chained calls like ->onConnection()
, ->delay()
, are proxied to the job.
The Queueable
trait is the one that provides these fluent methods above and where the
properties like $connection
, $delay
, and others are set.
When the PendingDispatch
dispatches, either by explicitly calling the dispatch()
method or
implicitly on destruction, the Bus checks:
-
ShouldQueue: decides between dispatching to queue vs run inline. It also checks if it should dispatch only after the response is sent.
-
Connection/queue:
$job->connection
and$job->queue
(or config defaults). -
Timing:
$job->delay
,$job->timeout
,backoff()
,retryUntil()
-
Safety:
ShouldBeUnique/uniqueId()
,ShouldBeEncrypted
,afterCommit
The Queue Manager then picks the connector (Redis/Database/SQS), applies delay/visibility settings, and pushes a JSON payload that includes the job class, data, and all the collected metadata.
Payload and Serialization
When you dispatch a job in Laravel, the framework wraps it in a compact JSON “envelope” that workers can pop, decode, and run safely and repeatedly.
What’s inside the envelope
Below we're going to see some of the most important values that are stored in the JSON "envelope" that Laravel creates when dispatching a job.
-
job: Always
Illuminate\Queue\CallQueuedHandler@call
(the generic invoker). -
uuid and displayName: For tracking and logs.
-
data.commandName: Your job class name.
-
data.command: A serialized clone of your job object.
-
Timing properties:
timeout
,timeoutAt
,retryUntil
,backoff
,maxTries
,maxExceptions
-
tags: For metrics.
-
encrypted:
true
(only if the job implementsShouldBeEncrypted
).
How the serialization works under the hood
Below we're going to see how the serialization works for different types of data when dispatching a job.
-
Job object: The Bus clones your job and serializes it using PHP’s
serialize()
. Drivers store that serialized blob inside JSON (and may base64 it). -
Eloquent models: If your job uses the
SerializesModels
trait, each model becomes a lightweightModelIdentifier
(class, id, connection, relations). On the worker, Laravel re-hydrates models by re-querying the database—keeping payloads tiny and consistent. -
Closures: Closure-based jobs are serialized using the
laravel/serializable-closure
package. They work, but prefer concrete job classes for safer rolling deploys. -
Encryption: Jobs that implement the
ShouldBeEncrypted
interface have their serialized payload encrypted before being written to the connection.
JSON envelope example
{
"uuid": "b4c1e7c6-...",
"displayName": "App\\Jobs\\ProcessReport",
"job": "Illuminate\\Queue\\CallQueuedHandler@call",
"maxTries": 3,
"timeout": 30,
"backoff": [5, 30, 90],
"tags": ["important", "client:42"],
"data": {
"commandName": "App\\Jobs\\ProcessReport",
"command": "SERIALIZED JOB HERE"
}
}
Queue Connectors Deep Dive
Redis is the most popular Laravel queue connection, it's simple, fast and even better when you
pair it with Laravel Horizon
. We're going to check how it works under the hood, but for that
we need first to take a look at some concepts from Redis.
-
zset (sorted set): An ordered collection where each member has a numeric score. The items are retrieved by score order (e.g., timestamps for scheduling).
-
RPUSH: Appends a value to the tail of a list.
-
ZADD: Adds or updates a member in a sorted set with a score.
-
BLPOP: Blocking pop from the head of a list. It waits until an item is available (or timeout).
How the jobs are stored
Under the hood, it uses one list, plus two sorted sets.
-
Main queue (list): key
queues:default
-RPUSH
payloads here -
Delayed jobs (zset): key
queues:default:delayed
-ZADD
with score =now
+delay
-
Reserved jobs (zset): key
queues:default:reserved
-ZADD
with score =now
+visibilityTimeout
How popping works
The popping of jobs from the queue is done atomically, and it has three phases.
Migrating due items
It moves the items whose score <= now
from the delayed set to the main list, so they
become ready.
It moves the expired reserved items (visibility timeout passed) from the reserved set to the main list, so crashed/slow workers get retried.
Both these migration actions uses Lua
scripts for atomicity.
Reserving items
It runs a BLPOP
in the main list and immediately runs a ZADD
for the popped payload
into the reserved set with score = now + retry_after
(visibility timeout).
The visibility timeout can be configured in the retry_after
setting of the config/queue.php
file
in the redis
section.
The BLPOP
time can be configured in the same file and section, in the block_for
setting.
Running the job
After the BLPOP
, the worker executes the handle()
method of the job with all the
configured middleware, timeouts, etc.
How acknowledge and release works
We have three different paths when it comes to acknowledging or releasing the jobs.
-
Success: Remove the payload from the reserved
zset
. -
Retry: Increment attempts and move it to delayed sorted set with a new score.
-
Fail: Remove the payload from the reserved sorted set and record in
failed_jobs
.
Worker Internals
Now is time to learn a little more on how things work under the hood for the workers, when
you run php artisan queue:work
.
It is a long‑running daemon that boots your app once, then loops: pop → reserve → run → ack/retry → repeat. It’s fast, observable, and designed for safe restarts. Let's understand how it works under the hood.
When you run php artisan queue:work
, it boots the container and your app, and then it
enters a loop.
The worker asks the driver for a job. The driver atomically reserve the job with a visibility
window, the retry_after
settting.
The job is reconstructed from its JSON payload, dependencies are resolved, and the middlewares,
if any, are executed wrapping the handle()
method.
While running the job, a per-job timeout is enforced, and if the job overruns it, the job is killed. The job will be available again after the visibility timeout is reached.
In a success run, the job is removed from the reserved set and the JobProcessed
event
is fired.
If an Exception
happens, it increment attempts, compute backoff and release to the
delayed set.
If maxTries
/maxExceptions
/retryUntil
is hit, the job is marked as failed, added to
the failed_jobs
and the JobFailed
event is fired.
For each loop, the worker also do some housekeeping and checks for things like checking if
the app is in maintenance mode, checking if the max jobs setting was not reached as well
as handling graceful quit flags, for example when using the queue:restart
.
Below is a tiny and simple mental model using pseudocode for a better understanding the mechanics of the worker.
while (! shouldQuit()) {
if (! $job = $queue->pop($queues)) {
sleepOrBlock(); // e.g., Redis BLPOP
continue;
}
startTimeoutTimer($job->timeout); // pcntl on Unix
try {
runMiddlewareThenHandle($job);
ack($job);
dispatch(JobProcessed::class);
} catch (\Throwable $e) {
handleFailureOrRelease($job, $e); // backoff / fail
} finally {
stopTimer();
}
enforceLimits(); // max jobs, max time, quit flags
}
Laravel also offers some features to better orchestrate the jobs and the queues, let's see how some of these work.
Without Overlapping
This is a middleware provided by Laravel that prevents two workers from handling the same "thing" at the same time.
public function middleware(): array
{
return [
new WithoutOverlapping($this->userId),
];
}
Under the hood, when you add this middleware, it acquires an atomic cache lock cache()->lock($key, $ttl)
.
If the lock is held, the job isn’t processed now, it’s released back to the queue with a
small delay so another attempt can try later. On success/failure, the lock is released.
If the worker crashes, the lock auto-expires after its TTL, preventing deadlocks.
Unique Jobs
Laravel also provides a way for duplicate jobs not even entering the queue. On dispatch
,
Laravel tries to acquire a cache lock keyed by job class + uniqueId()
. If it can’t, the
new dispatch is skipped. The lock expires after the $uniqueFor
seconds is achieved (if set)
or when the job is released.
Laravel provides two different interfaces for this.
-
ShouldBeUnique: Lock lasts until the job finishes (or the TTL ends), preventing multiple queued or running duplicates.
-
ShouldBeUniqueUntilProcessing: Lock is released as soon as a worker starts processing, allowing a new copy to be queued while the current one runs (but not queued in parallel beforehand).
Expiring Keys
As we saw above, the WithoutOverlapping
middleware and the ShouldBeUnique
and
ShouldBeUniqueUntilProcessing
interfaces rely on cache locks with a TTL set. The locks
must expire to avoid permanent blocks in case of a worker dies. In Redis this uses the SET NX EX
under the hood.
-
SET: Writes a value to a key.
-
NX: Only set if the key does not already exist.
-
EX: Set a time-to-live so the key auto-expires after N seconds.
With this, Laravel releases the lock on completion, and if the process crashes, Redis expires it automatically.
Failures, Retries, and Backoff
Laravel has a consistent and safe way to handle retries and failures of jobs in the queue, so you don't have to worry when something goes wrong. Here's how it works under the hood.
When a worker pops a job from the queue, it increments the attempts on the payload. If a timeout or an Exception happens, instead of acknowledging and deleting the job, it will reappear again in the queue after the visibility window and it will increment the attempts once again when it's popped.
Before the job is again released to be retried, it checks the value from the backoff()
method or an explicit call to the releaseAfter()
method, and then it sends the job to
the delayed sorted set, incremeting now()
with one of these set values. If none of these
are found, the job is released to be attempted again immediately.
To check if the job should be retried, the worker checks the $tries
, $maxExceptions
properties and the retryUntil()
method. If the number of tries and exceptions exceed
the configured ones or now() > retryUntil()
, the job is then marked as failed.
When marked as failed, the worker throws the MaxAttemptsExceededException
exception,
deletes the job from the queue, records it in the failed_jobs
table, fires the JobFailed
event and calls the job failed()
method.
Below we can see the same job we had before, but with a better configuration for the retries and failure.
class ProcessReport implements ShouldQueue
{
use Queueable, SerializesModels;
public int $tries = 5;
public int $timeout = 30;
public int $maxExceptions = 3;
public function __construct(public Report $report) {}
public function backoff(): array
{
return [5, 30, 90];
}
public function retryUntil(): DateTime
{
return now()->addMinutes(30);
}
public function handle(): void
{
// Process report logic here
}
public function failed(?Throwable $exception): void
{
// Send notification of failure
}
}
Delays, Visibility, and Timeout
When handling jobs in the queues, timing is very important, and Laravel makes it easy to manage this. Three "clocks" are the most important: when a job becomes available (delay), how long it stays "locked" for a worker (visibility) and how long your code is allowed to run (timeout). Let's check how these work using the Redis connection.
Delay
Redis uses the delayed sorted set for this with a score now() + delay
. The delay can be
set when dispatching the job:
ProcessReport::dispatch($report)
->delay(now()->addMinutes(10));
Visibility
When the worker pops the message from the queue, the payload is moved to the reserved
sorted set with a score now() + retry_after
. This can be configured in the config/queue.php
file under the redis
section, and if the job is not acknowledged in time, the job is then
migrated back to the main queue for a possible retry.
Timeout
The worker will enforce a maximum time to live for the job. This can be set either in the
$timeout
property of the job itself for job-specific timeouts, or in the --timeout
option
when running the php artisan queue:work
command, that will apply to all jobs. After this
timeout is exceeded, the job is killed and after the set visibility period, it is migrated
back to the main queue for a possible retry.
Chains and Batches
Laravel's Bus gives us more powers than just running plain jobs. It offers a way to orchestrate sequential chains or parallel batches of jobs, opening a lot of new possibilities when managing jobs in the queue.
Chains
The concept of a Chain of jobs is quite simple, it runs jobs in order, and if any of these fails, the remaining jobs in the chain are skipped.
The payload of the jobs in the chain contain a serialized chained array with all the jobs on it. After a job is acknowledged, the worker then dispatches the next serialized job.
If a job fails, the remaining jobs in the chain are skipped and if you have defined a
catch()
callback, it will be run.
Bus::chain([
new ProcessPayment($order),
new GenerateInvoice($order),
new EmailInvoice($order),
])->onConnection('redis')
->onQueue('high')
->catch(fn (Throwable $e) => report($e))
->dispatch();
Batches
While the concept of a Chain is to run the jobs in sequence, a Batch is when you want to run multiple jobs in parallel. You can track the progress of a batch, handle completion and failure hooks, allow cancellation of it and even add more jobs to it.
Under the hood, all this tracking happens in the job_batches
table, where it tracks how
many jobs the batch has, how many are pending or failed, which ones failed and if the batch
is cancelled.
Each job in the batch will receive a batchId
, and when one of these jobs are complete or
fail, the job_batches
table is updated with the information. Using this tracking, the
batch offers us different hooks that we can run additional logic based in the state of
the batch.
class ImportVideoChunk implements ShouldQueue
{
use Batchable, Queueable, SerializesModels;
public function __construct(
public Video $video,
public ?int start,
public ?int end,
) {}
public function handle(): void
{
if ($this->batch()->cancelled()) {
return;
}
// Process video import logic here
}
}
$batch = Bus::batch([
new ImportVideoChunk(video: $video, end: 60),
new ImportVideoChunk(video: $video, start: 61, end: 120),
new ImportVideoChunk(video: $video, start: 121, end: 180),
new ImportVideoChunk(video: $video, start: 181, end: 240),
new ImportVideoChunk(video: $video, start: 241),
])->before(function (Batch $batch) {
// Runs when the batch is created, but no jobs were run
})->progress(function (Batch $batch) {
// Runs when a job from the batch has completed without errors
})->then(function (Batch $batch) {
// Runs when all jobs are completed without errors
})->catch(function (Batch $batch, Throwable $e) {
// Runs when a job from the batch fails
})->finally(function (Batch $batch) {
// Runs when the batch executed all the jobs
})->dispatch();
As you can see above, the hooks provide you access to a Batch $batch
object, that you
can use to inspect and manage the current batch. The same object can be accessed inside
the jobs with the $this->batch()
method provided by the Batchable
trait.
Conclusion
Just to sum everything, Queues are how Laravel turns slow, fragile work into fast, reliable experiences. You dispatch a small job, Laravel serializes it, a connector (like Redis) stores it, workers pop and run it through middleware, and the system retries or fails gracefully with clear events and metrics.
All of this achieved with an elegant and simple API that every developer can use for improving applications.
With this article you learned about the mechanics under the hood that empower the Laravel Queue System, how queues work in general, as well as having a quick look on how the Redis connector works with the Laravel Queue System.
I hope that you liked this article and if you do, don’t forget to share this article with your friends!!! See ya!