The problem with synchronous request handling
In a typical Laravel application, every slow operation blocks the user's request. Sending an invoice email, processing a payment, generating a report, or indexing data for search—all of these happen inline, and the browser waits. When operations take seconds or fail intermittently, request timeouts and poor user experience follow. Laravel queues explained: a hands-on guide for engineers shows us how to move this slow work into the background, keeping responses fast and reliable.
We've all felt the pain: a user submits an order form, the request hangs for 10 seconds while a third-party API responds slowly, and they refresh the page wondering if anything happened. Queues let us acknowledge the request immediately, then process the heavy lifting asynchronously. This article walks you through the mechanics, shows you how to build and dispatch jobs, manage workers in production, and handle retries when things go wrong.
Create a job with php artisan make:job
Jobs are the unit of work in Laravel's queue system. Each job encapsulates a single async task—sending an email, processing an image, updating a search index. The framework provides a command to scaffold them:
php artisan make:job SendInvoiceThis creates app/Jobs/SendInvoice.php. Let's look at what the generator gives us and how we'd extend it for a real scenario:
<?php
namespace App\Jobs;
use App\Models\Order;
use App\Mail\InvoiceMail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Mail;
class SendInvoice implements ShouldQueue
{
use Dispatchable, Queueable, SerializesModels, InteractsWithQueue;
public function __construct(public Order $order)
{
}
public function handle(): void
{
Mail::to($this->order->customer_email)
->send(new InvoiceMail($this->order));
}
}
The key points: The job implements ShouldQueue, telling Laravel this should run in a queue, not synchronously. We inject the Order model into the constructor and rely on SerializesModels to store only its ID in the queue, reconstructing it when the job runs. The handle() method is where the actual work happens—in this case, sending an email. When the worker processes this job, it calls handle() and the mail ships in the background.
For a real e-commerce platform, you might create additional jobs for charge processing, inventory updates, or notification batching. Each job is small, focused, and testable in isolation.
Dispatch jobs from anywhere in your application
Once you've defined a job, dispatching it is straightforward. You can queue it from a controller, a listener, a command, or even another job. Here's how you'd dispatch the invoice job when an order is placed:
<?php
namespace App\Http\Controllers;
use App\Jobs\SendInvoice;
use App\Models\Order;
use Illuminate\Http\Request;
class OrderController extends Controller
{
public function store(Request $request)
{
$order = Order::create($request->validated());
// Dispatch the job to the default queue
SendInvoice::dispatch($order);
return response()->json(['order_id' => $order->id], 201);
}
}
The user gets an immediate response. The email processing happens later, handled by a background worker. If email delivery takes 5 seconds, the user doesn't wait. If it fails, the queue system can retry it automatically.
You can customize dispatch behavior in several ways. Target a specific queue (useful for prioritization or separate workers):
SendInvoice::dispatch($order)->onQueue('billing');Delay the job for 10 minutes:
SendInvoice::dispatch($order)->delay(now()->addMinutes(10));Chain multiple jobs so they run sequentially:
Bus::chain([
new ProcessPayment($order),
new SendInvoice($order),
new UpdateInventory($order),
])->dispatch();In production at a ride-hailing platform, you might dispatch a job immediately when a ride is requested, another when the driver accepts, and a third when the ride completes—each with its own retry logic and timing. Chaining ensures the sequence, and separate queues let you scale workers independently.
Run workers in development and production
Jobs sit in a queue (stored in the database, Redis, or another backend) until a worker picks them up and executes them. In development, you start a worker with:
php artisan queue:workThis blocks the terminal and processes jobs as they arrive. It's useful for testing: you queue a job, the worker picks it up immediately, and you see any errors. Stop the worker with Ctrl+C.
In production, you can't rely on a blocking process. Instead, use Supervisor (or systemd) to keep workers running, restart them if they crash, and manage multiple workers for concurrency. Here's a Supervisor configuration:
[program:laravel-queue-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /path/to/artisan queue:work redis --sleep=3 --tries=3
autostart=true
autorestart=true
numprocs=4
redirect_stderr=true
stdout_logfile=/var/log/laravel-queue.log
This spawns 4 worker processes, each monitoring the same queue. If one crashes, Supervisor restarts it. The --sleep=3 flag tells workers to rest 3 seconds between polling (reducing CPU when the queue is empty), and --tries=3 sets the default retry count.
In a trading application handling high-frequency operations, you'd run many workers and possibly separate worker pools for different queues. Monitor queue depth and scale workers based on backlog. If invoicing jobs are piling up but notification jobs aren't, run more workers on the billing queue and fewer on notifications.
You can also use Laravel's daemon mode for simpler deployments:
php artisan queue:work --daemonThis keeps the process alive longer, reducing Laravel bootstrap overhead, but requires careful memory management.
Handle retries, backoff, and failed jobs
Jobs sometimes fail. A third-party API times out. A database connection drops momentarily. The queue system should retry intelligently, not immediately give up. Define retry behavior in your job:
<?php
namespace App\Jobs;
use App\Models\Order;
use App\Mail\InvoiceMail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Mail;
class SendInvoice implements ShouldQueue
{
use Dispatchable, Queueable, SerializesModels, InteractsWithQueue;
public $tries = 5;
public $backoff = [10, 30, 60, 120, 300];
public function __construct(public Order $order)
{
}
public function handle(): void
{
Mail::to($this->order->customer_email)
->send(new InvoiceMail($this->order));
}
public function failed(\Throwable $exception): void
{
\Log::error('Invoice send failed for order ' . $this->order->id, [
'exception' => $exception->getMessage(),
]);
// Notify ops team, trigger manual review, etc.
}
}
$tries sets the maximum attempts (5 in this case). $backoff defines the seconds to wait between attempts: 10, 30, 60, 120, then 300 seconds. So the job retries at 10s, 40s, 100s, 220s, and 520s after the initial attempt. If all retries fail, the failed() method is called, where you can log, alert, or store it for manual investigation.
Inspect failed jobs in the database:
php artisan queue:failedThis lists all permanently failed jobs. To retry a specific job:
php artisan queue:retry abc123def456Or retry all failed jobs:
php artisan queue:retry allIn a SaaS application handling billing, you'd want robust retry logic for payment processing. A transient network error shouldn't immediately mark a charge as failed. But after 5 retries over several minutes, you'd flag it for manual review or attempt an alternative payment method.
You can also implement exponential backoff as a method:
public function backoff(): array
{
return [10, 30, 60];
}
// or for exponential growth:
public function backoff(): array
{
return collect(range(1, 5))
->map(fn($attempt) => $attempt * 60)
->toArray();
}
Common mistakes
- Forgetting to run workers. The queue is only half the system. If no worker is running, jobs sit forever. Use Supervisor or systemd in production to keep workers alive.
- Serializing non-model objects. Only models using
SerializesModelsare safely stored and reconstructed. If you inject a service or complex object, it may not serialize properly. Pass simple data or model IDs instead. - Not handling failed jobs. Without a
failed()method or proper alerting, failed jobs disappear into logs. Implement a strategy to detect and retry them manually. - Too many retries or too few. Setting
$tries = 100can clog your queue with flaky jobs. Be conservative: 3–5 retries with sensible backoff is usually enough. If something fails 5 times in 10 minutes, it's probably a real error. - Mixing sync and async in testing. In tests, use
Queue::fake()to assert jobs were dispatched without running workers. Then test the job'shandle()method directly, separate from dispatch logic.
Key takeaways
- Jobs encapsulate async work. Create them with
php artisan make:job, implementShouldQueue, and define your logic inhandle(). - Dispatch jobs from anywhere:
YourJob::dispatch($data). Users get instant responses while slow operations run in the background. - In development, run
php artisan queue:workto process jobs immediately. In production, use Supervisor to manage persistent worker processes. - Set
$triesand$backoffto retry intelligently. Implementfailed()to handle permanent failures and alert your team. - Monitor failed jobs with
php artisan queue:failedand retry them manually if needed. This gives you a safety net for transient errors. - Keep jobs focused and testable. Dispatch jobs from controllers, listeners, or other jobs. Chain them if the order matters. Use separate queues for different priorities.






Comments
Loading comments…