Laravel queues move slow work out of the HTTP request and hand it to a background worker, so your user gets a response in milliseconds while the email, the PDF export, or the third party API call finishes on its own time. A job is the unit of work, a queue worker is the long running process that pulls jobs off the queue and runs them, and a failed job is one that used up its retries and landed in the failed_jobs table.

Most queue tutorials stop at the first dispatch() call. Production starts there. You already know queues make requests faster, and that part takes about 10 minutes to set up. The pain arrives in week two, when a deploy leaves workers running last week's code, a retry emails the same customer twice, and nobody notices 400 rows quietly stacking up in failed_jobs.

This Laravel queue tutorial walks the full loop. You will pick a connection, write and dispatch Laravel jobs, run a queue worker that survives deploys, tune retries and timeouts so work is not duplicated, and handle failed jobs so they get fixed instead of forgotten. Every section includes the code and the Artisan commands you actually run.

Key Takeaways

What Laravel queues actually do

By default a fresh Laravel app uses the sync connection, which is not really a queue at all. Dispatching a job on sync runs it immediately, inside the same request, on the same PHP process. That is useful in tests and misleading in production, because your queued code looks asynchronous and behaves synchronously.

Switch the connection and the shape of the request changes. The controller pushes a serialized job payload onto a queue store and returns. A separate process picks the payload up, rebuilds the job object, and calls handle(). Laravel queues rely on three named pieces.

  • Connection, the backing store defined in config/queue.php, such as database, redis, or sqs.

  • Queue, a named lane inside that connection. default, emails, and exports can share one Redis connection and be worked at different priorities.

  • Worker, a long running PHP process started with php artisan queue:work that loops, reserves a job, runs it, and deletes it.

QUEUE_CONNECTION=redis

That single environment variable is the switch. Everything else in this guide is about making the worker behave under load, deploys, and failure. For the full API surface, the Laravel queue documentation is the reference, and Laravel 9 through 12 all follow the same model.

How to choose a Laravel queue connection

Pick the simplest connection that survives your traffic. Most teams start on database and move to Redis once the jobs table becomes the bottleneck.

Connection

Best for

Trade-off

sync

Local development and tests

Runs inline, so it hides every timing bug

database

Small apps, low thousands of jobs per day

Adds write load to the app database and locks rows under many workers

redis

Most production apps, high throughput, Horizon

One more service to run and monitor

sqs

AWS estates and spiky workloads

Managed and durable, with a 15 minute delay ceiling and per message size limits

New Laravel 11 and 12 apps ship the jobs and failed_jobs migrations in the default skeleton, so php artisan migrate is all the database driver needs. On an older codebase, generate them first.

php artisan make:queue-table
php artisan make:queue-failed-table
php artisan migrate

On Laravel 9 and 10 those commands are php artisan queue:table and php artisan queue:failed-table. For Redis, point QUEUE_CONNECTION at redis and install PhpRedis or Predis as described in the Laravel Redis documentation. Redis is also what Horizon requires, and that dashboard matters more than raw speed for most teams.

One rule saves a bad afternoon. Do not put the queue on the same Redis database you use for cache, because php artisan cache:clear can flush your pending jobs along with it.

Writing and dispatching Laravel jobs

Laravel jobs are plain PHP classes with a constructor and a handle() method. Generate one with Artisan. If you want the related generator flags in one place, the php artisan make:model cheatsheet covers the model and migration options you will reach for next.

php artisan make:job SendInvoiceEmail
namespace App\Jobs;

use App\Mail\InvoicePaid;
use App\Models\Invoice;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Mail;

class SendInvoiceEmail implements ShouldQueue
{
    use Queueable;

    public int $tries = 3;
    public int $timeout = 30;

    public function __construct(public Invoice $invoice)
    {
    }

    public function handle(): void
    {
        Mail::to($this->invoice->customer_email)
            ->send(new InvoicePaid($this->invoice));
    }
}

Laravel 11 and 12 fold the old traits into a single Queueable trait. On Laravel 9 and 10 the generated stub still uses Dispatchable, InteractsWithQueue, Queueable, and SerializesModels separately, and the behaviour is the same.

The constructor argument matters more than it looks. Model serialization stores only the primary key of the Invoice and refetches the record when the worker runs the job, which keeps payloads small and the data fresh. If that record is deleted before the worker reaches it, the job throws a ModelNotFoundException. Add public bool $deleteWhenMissingModels = true; when a missing record simply means the work is no longer needed.

SendInvoiceEmail::dispatch($invoice);

SendInvoiceEmail::dispatch($invoice)->onQueue('emails');

SendInvoiceEmail::dispatch($invoice)->delay(now()->addMinutes(5));

SendInvoiceEmail::dispatch($invoice)->afterCommit();

That last call prevents the most common queue bug in the wild. Dispatch inside a database transaction and a fast worker can pick the job up before the transaction commits, so the row it needs does not exist yet. Either call afterCommit() per dispatch, or set 'after_commit' => true on the connection in config/queue.php and stop thinking about it.

Queued work is also where a lot of Laravel boilerplate lives, the job class, the mail class, the migration, and the test that proves the job was dispatched. If you would rather describe the feature and review the result, Try StackMivo Now and see how AI code generation produces production-ready Laravel apps, queued jobs and Pest tests included.

Running a Laravel queue worker in production

A Laravel queue worker is a PHP process in a loop. Start one with a single command.

php artisan queue:work redis --queue=high,default --tries=3 --timeout=60 --max-time=3600

Read those flags as an operations contract. --queue=high,default drains the high lane first. --timeout kills a job that hangs, and it must stay lower than the retry_after value on the connection, otherwise the same job gets released and runs twice at once. --max-time retires the process after an hour, so any leaked memory goes away with it.

Use queue:work, not queue:listen. The listener reboots the framework for every job, which is slower but picks up code changes, so it belongs in local development only.

Keep workers alive and current

Workers die. A supervisor process restarts them. Most teams run Supervisor on the server with numprocs set to the number of concurrent workers they want, or run Laravel Horizon on Redis for auto scaling, per queue metrics, and a dashboard that shows throughput and failures.

Then there is the deploy problem. A worker loads your application into memory when it boots and keeps running that copy. Ship new code and the worker keeps executing the old classes until something restarts it.

A Laravel team lead at a mid-size SaaS shipped a fix for a broken invoice PDF on a Friday afternoon. The web app served the corrected version immediately. The workers did not, because the deploy script never restarted them, and they kept generating the broken PDF from the code loaded that morning. The bug looked fixed in staging, fixed in the browser, and very much alive in the customer's inbox until Monday. The fix is one line at the end of the deploy.

php artisan queue:restart

That command tells every worker to finish its current job and exit, and Supervisor or Horizon starts fresh processes on the new code. Anything that deploys for you should run it. Platforms with one-click deployment handle migrations, cache clearing, and queue restarts as part of the release, which removes the most common way Laravel queues drift out of sync with the app. Get started free if you want that release step handled for you.

Retries, timeouts, and backoff without duplicate work

Retries are the reason Laravel queues feel reliable, and the reason they duplicate work. A job that fails on attempt one runs again on attempt two, and the side effects of attempt one do not roll back.

public int $tries = 5;

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

public function retryUntil(): \DateTime
{
    return now()->addHours(2);
}

$tries caps attempts. $backoff spaces them out, and an array applies each value in turn before repeating the last one, which gives you escalating delays for free. retryUntil() replaces the attempt count with a deadline, which fits work that is worthless after a window, like a delivery time update.

Now the part that costs money. Every retry is a second execution of code that may have partly succeeded.

A small e-commerce team queued an order confirmation that also wrote a loyalty points row and called a shipping API. The shipping call timed out, the job retried twice, and the customer received three confirmation emails and three sets of points. Nothing failed loudly. The queue did exactly what it was told. Splitting the work into three smaller jobs and keying the points write on the order id fixed it in an afternoon.

Three guards cover most cases.

  • Make handlers idempotent. Use updateOrCreate, a unique constraint, or a stored idempotency key so a second run is a no-op.

  • Implement ShouldBeUnique so the same job for the same id cannot sit on the queue twice, and reach for WithoutOverlapping when two different jobs touch the same record.

  • Split fat jobs. One job per external side effect means a retry repeats one call instead of five.

Queues carry the money path in checkout and fulfilment flows, so this is where the guards earn their keep. The pattern is identical whether you are building an e-commerce app or a nightly reporting pipeline.

Handling failed jobs the right way

When a job runs out of attempts, Laravel writes it to the failed_jobs table with the payload and the exception, and then stops caring. Nothing retries it. Nothing alerts you. That table is the part of Laravel queues teams forget to watch.

Give every important job a failed() hook so the failure leaves a trail somebody will see.

public function failed(\Throwable $e): void
{
    $this->invoice->update(['delivery_status' => 'failed']);

    Log::error('Invoice email failed', [
        'invoice_id' => $this->invoice->id,
        'error' => $e->getMessage(),
    ]);
}

Then learn four commands.

php artisan queue:failed
php artisan queue:retry 9a1f7c4e-1f2b-4c3a-9b77-2f0d8e1c5a44
php artisan queue:retry all
php artisan queue:prune-failed --hours=168

queue:failed lists what broke and why. queue:retry pushes one job or every job back onto the queue after you deploy the fix. queue:prune-failed trims old rows and belongs in the scheduler so the table does not grow forever. Add one global listener so a failure reaches your error tracker the moment it happens.

use Illuminate\Queue\Events\JobFailed;
use Illuminate\Support\Facades\Queue;

Queue::failing(function (JobFailed $event) {
    Log::critical('Queue job failed', [
        'connection' => $event->connectionName,
        'job' => $event->job->resolveName(),
        'exception' => $event->exception->getMessage(),
    ]);
});

Horizon gives you the same visibility with a UI, full stack traces, and per queue wait times, which is usually the moment a Redis queue pays for itself. Whatever you run, the rule holds. A failed job nobody sees is a feature that silently does not work.

Where to go from here with Laravel queues

Laravel queues are simple to switch on and easy to run badly. The setup is one environment variable. The reliability comes from four habits. Pick a connection that matches your throughput and keep the queue off your cache database. Dispatch with afterCommit() so workers never race your transactions. Run queue:work under Supervisor or Horizon and restart it on every deploy. Treat retries as guaranteed rather than exceptional, and make each handler safe to run twice.

Start with the smallest useful move. Take the slowest thing in your busiest controller, the report, the export, the third party call, and push it into a job this week. Watch failed_jobs for a few days after that and you will learn more about your application than a month of profiling would teach you.

Queues are the part of Laravel that turns a working app into a dependable one. Get the worker, the retries, and the failures right, and the rest of your backend can grow behind them without a single user waiting on it.

Build your Laravel app with AI

Skip the boilerplate. StackMivo turns a plain-English prompt into production-ready Laravel apps, with models, migrations, controllers, CRUD, auth, and tests.

Get started free