How the Queue Worker Loop Actually Works

How the Queue Worker Loop Actually Works

Master Laravel queues by understanding what happens behind the scenes when jobs are dispatched and processed. This guide explores queue workers, model serialization, retries, failed jobs, chaining, and batching—key concepts for building reliable applications and succeeding in Laravel certification exams.

Steve McDougall

Steve McDougall

June 25, 2026

Queues are one of those Laravel features that most developers are comfortable with but few can explain precisely. You create a job, dispatch it, run php artisan queue:work, and things happen. When they do not happen the way you expect, debugging it feels like guesswork.

That is the gap this article is designed to close. We are going to follow a job from the moment it is dispatched to the moment the worker considers it done. Along the way we will cover the parts that the certification exam specifically cares about: model serialization, retry behaviour, the failure pipeline, job chaining, and batching.

Article 5 covered how the event system works and how queued listeners relate to jobs. This article is the natural follow-on, and it goes deeper into the queue mechanics that both dispatched jobs and queued listeners share.


Dispatching a Job: What Actually Happens

When you call ProcessOrder::dispatch($order), the job does not run immediately, even if it looks like it might. The dispatch method hands the job to the Dispatcher (the bus), which checks whether the job implements ShouldQueue. If it does, the bus serializes the job and pushes it to a queue backend.

The queue backend might be Redis, a database table, Amazon SQS, or any other driver you have configured. The serialized job lands in a queue as a payload, waiting to be picked up.

The php artisan queue:work command starts a worker process that runs a loop. In each iteration of that loop, the worker pulls one job off the queue, runs it, and decides what to do with the result. That loop is the engine. Understanding it is what this article is about.


SerializesModels: The Trap That Catches Everyone

In Laravel 13, the job boilerplate has been simplified. New jobs use a single trait:

      use Illuminate\Foundation\Queue\Queueable;

class ProcessUserData implements ShouldQueue
{
    use Queueable;

    

This Queueable trait from Illuminate\Foundation\Queue consolidates what used to be four separate traits: Dispatchable, InteractsWithQueue, Queueable, and SerializesModels. The model serialization behaviour is still fully present. The trait just bundles it cleanly.

SerializesModels is the behaviour that matters most for understanding job execution, and it is directly behind question 54 in the senior exam bank.

When a job is serialized, SerializesModels does not store the full Eloquent model in the payload. It stores the model's class name and primary key. When the worker picks up the job and deserializes it, SerializesModels fetches a fresh instance of the model from the database using that key.

      use Illuminate\Foundation\Queue\Queueable;

class ProcessUserData implements ShouldQueue
{
    use Queueable;

    public function __construct(public User $user) {}

    public function handle(): void
    {
        Log::info($this->user->status); // fetched fresh from the database
    }
}

// In the controller:
$user = User::find(1);
ProcessUserData::dispatch($user)->delay(now()->addMinutes(2));
$user->update(['status' => 'verified']); // happens after dispatch

    

The exam asks what gets logged when the job runs two minutes later. The answer is verified, not whatever the status was at dispatch time. The job re-fetches the model when it runs, so it sees the current state of the database, not the state that existed when dispatch was called.

This is a feature, not a bug. Serializing an entire Eloquent model with all its relations would bloat your queue payload and lead to stale data bugs. Storing just the key and re-fetching is the right behaviour. But you need to know it is happening.


The Worker Loop in Detail

A queue worker is a long-running PHP process. Here is what happens in each iteration:

The worker calls the queue backend asking for the next available job. If one exists, the backend atomically moves it from the available queue to a "reserved" state, preventing other workers from picking up the same job.

The worker deserializes the payload. SerializesModels rehydrates any models. The job's handle method is resolved and called through the service container, the same way middleware and listeners are resolved. Dependencies in handle are automatically injected.

If handle completes without throwing, the job is deleted from the queue. Done.

If handle throws an exception, the worker catches it and checks whether the job has retries remaining. If it does, the job is released back to the queue with a delay calculated by the backoff strategy. If it has exhausted its retries, it is moved to the failed jobs table and the failed method is called.


Retries, Timeouts, and Backoff

These three properties control what happens when a job does not succeed on the first try:

      class ProcessInvoice implements ShouldQueue
{
    public int $tries = 3;
    public int $timeout = 30;
    public int $backoff = 10;

    public function handle(): void
    {
        // ...
    }

    public function failed(Throwable $exception): void
    {
        Log::error('Invoice processing failed permanently', [
            'error' => $exception->getMessage(),
        ]);
    }
}

    

$tries is the maximum number of attempts. The default, if you do not set it, is determined by the --tries flag you pass to queue:work, which defaults to 1 if not specified. This is question 114 in the senior bank: if you do not set $tries on the job and do not pass --tries to the worker, a failing job does not retry. It goes straight to failed.

$timeout is how long a single attempt is allowed to run before the worker process kills it. The default is 60 seconds. A timed-out attempt counts as a failed attempt.

$backoff controls the delay between retries. You can provide an array for progressive backoff:

      public array $backoff = [10, 30, 60];

    

The first retry waits 10 seconds, the second 30, the third 60. After that, the job is considered permanently failed.


The Failed Job Pipeline

When a job exhausts its retries or throws an unhandleable exception, it enters the failure pipeline.

First, the failed method on the job class is called if it exists. This is your chance to clean up, notify someone, or log context-specific information about the failure.

Then the job is written to the failed_jobs table (configured in config/queue.php). The payload, exception message, and connection details are all stored there.

      public function failed(Throwable $exception): void
{
    // Notify the user their order could not be processed
    $this->user->notify(new OrderProcessingFailed($exception));
}

    

You can inspect failed jobs with php artisan queue:failed, retry individual ones with php artisan queue:retry {id}, and retry all of them with php artisan queue:retry all. Retrying pushes the job back onto the queue as a fresh attempt.

One important nuance from question 219: the failed method is called when the job is moved to the failed state after exhausting all retries. It is not called on each individual failed attempt. If you want to run logic on each failed attempt, catch exceptions inside handle yourself.


Dispatching Inside a Transaction

This is the most dangerous queue antipattern in Laravel, and it appears in the senior bank as question 228. It deserves a clear explanation.

      DB::transaction(function () {
    $order = Order::create([...]);
    ProcessOrderJob::dispatch($order);
});

    

The job is pushed to the queue inside the transaction. At the moment of dispatch, the transaction has not committed yet. The $order record does not exist in the database from any other connection's perspective. But the worker could pick up the job almost immediately after the push, before the transaction commits, and find nothing when it tries to re-fetch the model.

Even if the timing works out, if anything after the dispatch throws and the transaction rolls back, you now have a job on the queue for an order that was never committed.

The fix is $afterCommit:

      class ProcessOrderJob implements ShouldQueue
{
    public bool $afterCommit = true;
}

    

With this set, the bus holds the job and only releases it to the queue backend after the outermost transaction has committed. Rollback means the job is discarded. This should be your default for any job that operates on data created within a transaction.


Job Chaining

Chaining lets you define a sequence of jobs that run one after another, where each job only starts if the previous one succeeded.

      Bus::chain([
    new OptimizeImages($product),
    new UpdateSearchIndex($product),
    new NotifySubscribers($product),
])->dispatch();

    

The chain is stored as part of the first job's payload. When OptimizeImages finishes successfully, it dispatches UpdateSearchIndex. When that finishes, it dispatches NotifySubscribers.

Question 191 in the senior bank asks what happens when the second job in the chain fails. The answer is the chain stops. NotifySubscribers never runs. The failed job follows the normal failure pipeline, and nothing downstream of it executes.

You can attach catch callbacks to a chain for when any job in it fails:

      Bus::chain([...])
    ->catch(function (Throwable $e) {
        Log::error('Chain failed', ['error' => $e->getMessage()]);
    })
    ->dispatch();

    

Chaining is sequential by design. If you need jobs to run in parallel, you want batching instead.


Job Batching

Batching dispatches a group of jobs and lets you react to the batch as a whole: when all jobs complete, when any job fails, and when the batch finishes regardless of outcome.

      Bus::batch([
    new ProcessImage($id),
    new NotifyUser($id),
])
->then(function (Batch $batch) {
    Log::info('All jobs completed');
})
->catch(function (Batch $batch, Throwable $e) {
    Log::error('A job failed', ['error' => $e->getMessage()]);
})
->finally(function (Batch $batch) {
    Log::info('Batch finished', ['failed' => $batch->failedJobs]);
})
->name('media-processing')
->dispatch();

    

Question 719 asks which component tracks batch state in Laravel 13. The answer is DatabaseBatchRepository. It stores batch progress, counts of pending and failed jobs, and completion status in the job_batches table. The then, catch, and finally callbacks are serialized into the batch record and fired by the repository as the batch progresses.

Each job in a batch should use the Batchable trait and check whether the batch has been cancelled before doing work:

      use Illuminate\Foundation\Queue\Queueable;

class ProcessImage implements ShouldQueue
{
    use Queueable, Batchable;

    public function handle(): void
    {
        if ($this->batch()->cancelled()) {
            return;
        }

        // Process the image
    }
}

    

This is question 781. The Batchable trait gives the job access to the batch object, and checking cancelled() is how a job gracefully bows out when another job in the batch has already failed and the batch was configured to cancel on failure.


The ShouldBeUnique Contract

When a job implements ShouldBeUnique, Laravel uses an atomic cache lock to ensure only one instance of that job is on the queue at a time. Subsequent dispatches of the same job are silently discarded until the lock is released.

      class SyncProductInventory implements ShouldQueue, ShouldBeUnique
{
    public function __construct(public int $productId) {}

    public function uniqueId(): string
    {
        return "sync-inventory-{$this->productId}";
    }
}

    

The uniqueId method scopes uniqueness. With this implementation, you can have one SyncProductInventory job per product in the queue simultaneously, but not two for the same product.

Question 659 in the senior bank probes this. The lock is acquired when the job is dispatched and released when the job finishes running, not when it is picked up by a worker. This is worth knowing because there is a gap between dispatch and execution where the lock is held.


Why This Matters for Your Certification

The queue is mid-to-senior territory for a reason. The dispatch API is easy. The exam questions are about what happens inside the worker, not outside it.

SerializesModels and the re-fetch behaviour is tested directly. The retry and timeout defaults are tested. The failure pipeline, including when failed() is called and what happens after it, comes up regularly. Chain failure propagation is a favourite trap. The dispatch-inside-transaction problem is arguably the most important real-world gotcha in this entire topic, and it has a dedicated question in the bank.

If you can trace a job from dispatch() through serialization, the worker loop, retry logic, and into the failure pipeline, you are well prepared for everything the queue section of the exam can throw at you.

The next article moves into one of the most misunderstood topics in the framework: how to write genuinely testable Laravel code, what Bus::fake() and Event::fake() actually do under the hood, and why mocking through the container is almost always better than mocking directly.

More certificates.dev articles

Get the latest news and updates on developer certifications. Content is updated regularly, so please make sure to bookmark this page or sign up to get the latest content directly in your inbox.