A request that sends an email, generates a PDF, or calls a slow third-party API inline makes the user wait for all of that to finish before getting a response — Laravel's queue system exists specifically to move that work out of the request cycle, dispatching it to run in the background instead.
1. Creating a job
php artisan make:job SendWelcomeEmail
namespace App\Jobs;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class SendWelcomeEmail implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public User $user) {}
public function handle(): void
{
// the actual slow work — sending the email — happens here,
// in a background worker process, not during the original request
Mail::to($this->user)->send(new WelcomeEmail($this->user));
}
}
Implementing ShouldQueue is what actually makes this a background job rather than a plain synchronous class — without it, dispatching the job just runs handle() immediately inline, defeating the entire purpose.
2. Dispatching it
SendWelcomeEmail::dispatch($user);
This returns to the calling code immediately — it doesn't wait for handle() to actually run. The job is serialized and pushed onto the configured queue connection, to be picked up and executed by a separate worker process.
3. Choosing a queue driver
// .env
QUEUE_CONNECTION=database # simplest — jobs stored in a database table
QUEUE_CONNECTION=redis # faster, the standard production choice
The database driver needs a jobs table (php artisan queue:table then migrate) and works fine for low-to-moderate volume without adding new infrastructure. Redis is the more common production choice once volume grows, since it's built specifically for this kind of fast, ephemeral queue workload rather than adapting a general-purpose relational table to it.
4. Running a worker
php artisan queue:work
This is the process that actually pulls jobs off the queue and executes them — without a worker running continuously, dispatched jobs just sit in the queue indefinitely, never processed. In production, a worker needs to run as a persistently supervised background process (Supervisor is the standard tool for this on a typical VPS), not a one-off terminal command.
5. Handling failures and retries
class SendWelcomeEmail implements ShouldQueue
{
public $tries = 3;
public $backoff = 60; // seconds between retry attempts
public function failed(\Throwable $exception): void
{
// runs after every retry attempt has been exhausted
Log::error('Welcome email permanently failed', ['user_id' => $this->user->id]);
}
}
A job that throws an exception is automatically retried up to $tries times before being moved to a failed_jobs table — failed() runs only once all retries are exhausted, making it the right place for cleanup or alerting logic, not a handler for every individual failed attempt.
6. What belongs in a queue vs. what should stay synchronous
Anything the user is actively waiting to see the result of — a form validation error, the actual page they're about to view — has to stay synchronous. Anything the user doesn't need to wait for before the response finishes (sending a confirmation email, logging an analytics event, generating a report to download later, calling a third-party webhook) is a strong candidate for a queued job. The general rule: if delaying it by a few seconds wouldn't break the user's immediate experience, it probably belongs in a queue.