A customer clicks "Place order" and stares at a spinner for four seconds. In those four seconds the server generates a PDF invoice, sends an email, calls an SMS gateway and perhaps notifies the accounting system. None of that has to finish before the customer gets a response.
That is exactly what Laravel queues are for: the request does the essential work, pushes the slow tasks onto a queue and responds immediately. A separate process, the worker, picks those tasks up and runs them in the background.
This guide goes from your first job to running workers reliably in production with Laravel 13.
Short answer: Laravel queues move slow work such as sending email, generating PDFs and calling external APIs out of the user's request: you dispatch it as a job, and a separate worker (
php artisan queue:work) runs it in the background. In production, keep the worker running with systemd or Supervisor, definetries,backoffandtimeouton the job, and runphp artisan queue:restartafter every deploy.
What to send to the queue
Any task with these three traits is a good candidate:
- It's slow or its duration is unpredictable: sending email and SMS, generating PDFs, processing images, calling external APIs.
- The user isn't waiting for the result; it's enough to know the task was accepted.
- It can fail and should be retried: an SMS gateway occasionally doesn't respond, and that shouldn't make placing an order fail.
By contrast, form validation, saving the order itself and anything the page response depends on should stay in the request.
Creating a job
This command generates a job class:
php artisan make:job SendInvoice
And here is the finished version:
<?php
namespace App\Jobs;
use App\Mail\InvoiceMail;
use App\Models\Order;
use App\Services\InvoicePdf;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Throwable;
class SendInvoice implements ShouldQueue, ShouldBeUnique
{
use Queueable;
/** Maximum number of attempts */
public int $tries = 5;
/** Maximum run time per attempt, in seconds */
public int $timeout = 120;
/** The uniqueness lock is held for at most one hour */
public int $uniqueFor = 3600;
public function __construct(public Order $order)
{
}
/** Delay between attempts: 10 seconds, 1 minute, 5 minutes, 15 minutes */
public function backoff(): array
{
return [10, 60, 300, 900];
}
public function uniqueId(): string
{
return (string) $this->order->id;
}
public function handle(InvoicePdf $pdf): void
{
// Already sent? Do nothing (idempotency)
if ($this->order->invoice_sent_at !== null) {
return;
}
$path = $pdf->generate($this->order);
Mail::to($this->order->email)->send(new InvoiceMail($this->order, $path));
$this->order->forceFill(['invoice_sent_at' => now()])->save();
}
public function failed(?Throwable $exception): void
{
Log::error('Sending invoice failed', [
'order_id' => $this->order->id,
'error' => $exception?->getMessage(),
]);
}
}
A few notes on this class:
- In Laravel 13, the
Queueabletrait includesSerializesModels. Only the order's ID is stored in the queue, not the whole model, and the worker reloads the model fresh from the database when it runs. Soinvoice_sent_atalways reflects the current value. - Resolve dependencies (such as
InvoicePdf) inhandle, not in the constructor. The constructor should only take data that needs to be stored in the queue. InvoiceMailshould not implementShouldQueueitself; we're already inside a queued job, andsendruns right here.
Dispatching to the queue
In a controller or service:
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreOrderRequest;
use App\Jobs\SendInvoice;
use App\Models\Order;
use Illuminate\Support\Facades\DB;
class OrderController extends Controller
{
public function store(StoreOrderRequest $request)
{
$order = DB::transaction(function () use ($request) {
$order = Order::create($request->validated());
SendInvoice::dispatch($order)->afterCommit();
return $order;
});
return redirect()->route('orders.show', $order);
}
}
afterCommit() matters. Without it, a worker can pick up the job before the transaction commits, fail to find the order in the database, and the job fails with a ModelNotFoundException. To make this the default for every job, set 'after_commit' => true for the connection in config/queue.php.
Separate queues for work of different priorities are also useful:
SendInvoice::dispatch($order)->onQueue('emails');
GenerateMonthlyReport::dispatch()->onQueue('reports')->delay(now()->addMinutes(5));
Retries, backoff and timeout
Any job can fail: the network drops, an external service returns a 500. Three settings control what happens then:
| Setting | Meaning |
|---|---|
$tries |
How many attempts before the job counts as failed |
backoff() or $backoff |
Seconds to wait between attempts; an array sets each delay separately |
$timeout |
If an attempt runs longer than this, the worker kills it |
Values defined on the job take precedence over the --tries and --timeout options of queue:work.
Don't overlook one important relationship: each connection in config/queue.php has a retry_after value (90 seconds by default). If a job is still running after that long, the queue assumes the worker has died and hands the job to another worker. So timeout must always be a few seconds shorter than retry_after; otherwise a long job can run twice at the same time. In the example above, timeout is 120, so that connection's retry_after should be raised to something like 180.
ShouldBeUnique: once in the queue, not ten times
If a user double-clicks the button or an event fires twice, two identical jobs can end up in the queue. By implementing ShouldBeUnique and a uniqueId() method, while a SendInvoice for a given order is queued or running, further dispatches for the same order are ignored.
The uniqueness lock is released when the job completes successfully or exhausts all its attempts. $uniqueFor caps how long the lock is held, for the case where a worker dies mid-job. The lock lives in the cache, so the cache driver must support locks and be shared across all servers; Redis or database are fine, file on multiple servers is not.
Failed jobs and the failed method
When all attempts are used up, the job is recorded in the failed_jobs table and its failed() method runs. That's the place to log, alert the support team or flag the order as needing attention.
Commands you'll use often:
php artisan queue:failed # list failed jobs
php artisan queue:retry 5f1c... # retry one job by UUID
php artisan queue:retry all # retry all of them
php artisan queue:forget 5f1c... # remove one job from the list
php artisan queue:prune-failed --hours=168 # delete records older than a week
Add queue:prune-failed to the scheduler so the table doesn't grow forever.
Idempotency: assume every job runs twice
Queues don't guarantee exactly-once delivery. A job can be interrupted after sending the email but before saving invoice_sent_at, and the next attempt sends the email again. Or retry_after is misconfigured and two workers pick up the same job.
The right approach is to write jobs so that running them again does no harm:
- Check the state before doing the work, like
invoice_sent_atin the example above. - For financial operations, use a unique key in the database (for example a unique index on
payment_reference) so duplicate inserts are rejected. - If the external service accepts an idempotency key, send the order ID as that key.
- Decide which is worse: a duplicate email or a missing one. If you save
invoice_sent_atbefore sending, you get no duplicates but may lose an email; save it after, and the reverse is true. For invoices, a duplicate is usually the lesser evil.
Database or Redis
Laravel 13 uses the database queue driver by default, and new projects already include the migration for the jobs table.
| database | redis | |
|---|---|---|
| Setup | No extra service | Requires installing and maintaining Redis |
| Performance | Plenty for a few dozen jobs per minute | For high volume and low latency |
| Load on the database | Workers poll the table constantly | None |
| Horizon | Not supported | Supported |
For most small and medium sites, database is a good starting point. When job volume grows, you add more workers, or you want the Horizon dashboard, move to Redis; the job code doesn't change, only QUEUE_CONNECTION in .env.
Running workers in production
In development, php artisan queue:work in a terminal is enough. In production you need a process that's always up, restarts after a crash and starts automatically after a reboot. The two common tools for this are Supervisor and systemd. On Ubuntu 24.04, systemd is already there, so there's nothing extra to install.
A systemd unit
Create a template unit so you can run several workers at once:
[Unit]
Description=Laravel queue worker %i
After=network-online.target
Wants=network-online.target
[Service]
User=www-data
Group=www-data
WorkingDirectory=/var/www/example.com
ExecStart=/usr/bin/php artisan queue:work database --queue=emails,default --sleep=3 --tries=3 --max-time=3600
Restart=always
RestartSec=5
# Enough time for the current job to finish on stop
TimeoutStopSec=150
[Install]
WantedBy=multi-user.target
Then enable two workers:
sudo systemctl daemon-reload
sudo systemctl enable --now laravel-worker@1 laravel-worker@2
sudo systemctl status 'laravel-worker@*'
journalctl -u laravel-worker@1 -f
A few notes:
--queue=emails,defaultmeans the worker drains theemailsqueue first and then moves on todefault.--max-time=3600makes the worker exit gracefully after an hour, andRestart=alwaysbrings it back up. This prevents memory usage from creeping up over time.--max-jobsdoes the same based on the number of jobs processed.- On stop, systemd sends
SIGTERM, and Laravel's worker finishes the current job before exiting. SetTimeoutStopSechigher than thetimeoutof your longest job.
If you prefer Supervisor
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=/usr/bin/php /var/www/example.com/artisan queue:work database --sleep=3 --tries=3 --max-time=3600
user=www-data
numprocs=2
autostart=true
autorestart=true
stopwaitsecs=150
stdout_logfile=/var/www/example.com/storage/logs/worker.log
Then run sudo supervisorctl reread && sudo supervisorctl update.
Restart workers after every deploy
A worker is a long-running process that loads your application code into memory once. If you don't restart it after a deploy, it keeps running the old code, one of the most common reasons behind "I changed it but nothing happened".
In your deploy script, after updating the code and running migrations:
php artisan migrate --force
php artisan optimize
php artisan queue:restart
queue:restart doesn't kill anything; it just sets a flag in the cache. Each worker sees the flag after finishing its current job and exits, and systemd or Supervisor starts it again with the new code. Two prerequisites: the cache driver must not be array, and all workers must share the same cache.
Frequently asked questions
What is a job in Laravel?
A job is a class that implements ShouldQueue and performs one specific task in its handle method. You put it on the queue with dispatch, and a worker runs it in the background without the user waiting for it to finish.
Why are my Laravel queue jobs not running?
The most common reason is that no worker is running at all; the job simply sits in the jobs table or in Redis until a queue:work process picks it up. Other causes are a worker listening to a different queue name (the --queue option), or a worker that wasn't restarted after a deploy and is still running old code.
What is the difference between queue:work and queue:listen?
queue:work boots the application once and stays in memory, so it's fast but doesn't see code changes until it's restarted. queue:listen reboots the application for every job and is slower; it's only useful in development, and production should always use queue:work.
Where are failed Laravel jobs stored?
Once a job has used up all its attempts, it's recorded in the failed_jobs table. List them with php artisan queue:failed, retry them with queue:retry, and delete old records with queue:prune-failed.
Should I use Redis or the database driver for Laravel queues?
For small and medium sites, the default database driver is enough and needs no extra service. Move to Redis when job volume grows or you want Horizon; the job code stays the same and only QUEUE_CONNECTION changes.
Wrap-up
- Queue any task that is slow, not needed for the response, and liable to fail.
- Define
tries,backoffandtimeoutexplicitly on the job, and keeptimeoutbelowretry_after. - Dispatch inside transactions with
afterCommit(). - Use
ShouldBeUniqueto prevent duplicate jobs, but always write jobs to be idempotent. - Implement
failed()and prune thefailed_jobstable regularly. - Start with
databaseand move to Redis when you need to. - Run workers under systemd or Supervisor, and run
queue:restarton every deploy.
For more detail, see the official Laravel queues documentation: https://laravel.com/docs/queues