Every second your user waits for a confirmation email or a PDF to generate is a second they’re questioning whether your app actually works.
Why Laravel Queues Tutorial Content Always Starts Too Late
Most guides jump straight into php artisan queue:work without explaining why queues exist. Here’s the honest version: synchronous code kills user experience. When a user submits a form and your app sends an email, resizes an image, and pings a third-party API before returning a response, you’ve just made them sit through your server’s to-do list.
Queues fix this by decoupling time-consuming work from the HTTP request cycle. The user gets a response immediately. The heavy lifting happens in the background. That’s the whole idea — and once it clicks, you’ll start seeing queue opportunities everywhere in your codebase.
Laravel’s queue system is one of its genuinely great features. It abstracts over multiple backends (database, Redis, SQS, Beanstalkd) behind a consistent API. You pick the driver, write your job, dispatch it. The rest is configuration.
Setting Up Your First Queue: The Actual Minimum
Before anything else, you need a queue driver. For local development, the database driver is the path of least resistance.
php artisan queue:table
php artisan migrate
In your .env file:
QUEUE_CONNECTION=database
That’s it for local setup. For production, switch to Redis — it’s faster, more reliable, and handles failure better than a database table under load. Don’t argue with this one. Just use Redis.
QUEUE_CONNECTION=redis
Make sure you’ve pulled in the Predis package or the phpredis extension, then set your Redis config in config/database.php. Laravel’s official queue documentation covers every driver option if you need SQS or Beanstalkd instead.
Creating Your First Job Class
Generate a job with Artisan:
php artisan make:job SendWelcomeEmail
This drops a class into app/Jobs/. Open it up and you’ll see the scaffold Laravel gives you:
<?php
namespace App\Jobs;
use App\Models\User;
use App\Mail\WelcomeEmail;
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 SendWelcomeEmail implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public User $user) {}
public function handle(): void
{
Mail::to($this->user->email)->send(new WelcomeEmail($this->user));
}
}
The ShouldQueue interface is what tells Laravel to push this job onto the queue instead of running it inline. The SerializesModels trait handles Eloquent model serialization safely — it stores the model’s ID and rehydrates it when the job runs, so you’re not serializing an entire object graph. That detail matters more than it sounds.
Dispatching Jobs From Your Controllers
// Immediate dispatch (goes to queue)
SendWelcomeEmail::dispatch($user);
// Delayed dispatch — send in 5 minutes
SendWelcomeEmail::dispatch($user)->delay(now()->addMinutes(5));
// Dispatch on a specific queue
SendWelcomeEmail::dispatch($user)->onQueue('emails');
Keep your controller methods thin. Dispatch and move on. The job handles the rest.
Handling Failures Like a Professional
This is the part most Laravel queues tutorial posts skip, and it’s where things get genuinely painful in production. Jobs fail. Third-party APIs go down. Memory limits get hit. Your queue system needs to handle this gracefully — because it will happen, and probably on a Friday.
Configuring Retry Attempts
On your job class, set $tries and $timeout:
class SendWelcomeEmail implements ShouldQueue
{
public int $tries = 3;
public int $timeout = 30; // seconds
// ...
}
Laravel will retry the job up to three times before marking it as failed. You can also use exponential backoff to avoid hammering a struggling service:
public function backoff(): array
{
return [1, 5, 10]; // seconds between retries
}
The Failed Jobs Table
Run this migration to capture failed jobs:
php artisan queue:failed-table
php artisan migrate
When a job exhausts its retries, Laravel writes it to failed_jobs. You can inspect failures:
php artisan queue:failed
Retry a specific failed job:
php artisan queue:retry {id}
Retry all of them:
php artisan queue:retry all
Implement the failed() method on your job class to handle cleanup or alerting when a job dies permanently:
public function failed(\Throwable $exception): void
{
// Notify the user, log to Sentry, clean up temp files
logger()->error('SendWelcomeEmail failed', [
'user_id' => $this->user->id,
'error' => $exception->getMessage(),
]);
}
Running Workers in Production: What You Actually Need
Running php artisan queue:work in your terminal is fine for development. In production, that command needs to stay alive, restart after deployments, and recover from crashes. A terminal tab doesn’t cut it.
Supervisor is the standard answer on Linux servers. It’s a process monitor that keeps your workers running and restarts them when they die.
A basic Supervisor config at /etc/supervisor/conf.d/laravel-worker.conf:
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/html/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=4
redirect_stderr=true
stdout_logfile=/var/www/html/storage/logs/worker.log
stopwaitsecs=3600
numprocs=4 spins up four worker processes. Tune this based on your server’s CPU and your job throughput. There’s no magic number — profile it.
Critical Deployment Step
After every deployment, your workers need to be restarted. Otherwise they’re running old code. I’ve watched developers spend an hour debugging why jobs behave like the old version, and it’s always this.
php artisan queue:restart
Wire this into your deployment script. Every time. No exceptions.
Horizon for Redis Queues
If you’re on Redis, install Laravel Horizon. It gives you a real-time dashboard, job metrics, and a much cleaner way to manage worker configuration in code rather than Supervisor conf files. It’s a composer require and twenty minutes of setup — and it pays back immediately in visibility alone.
composer require laravel/horizon
php artisan horizon:install
php artisan horizon
Advanced Patterns Worth Knowing
Once you’re comfortable with the basics, these patterns solve problems you’ll actually hit.
Job Chaining
Run jobs in sequence, where each one only starts if the previous one succeeded:
Bus::chain([
new ProcessUpload($file),
new GenerateThumbnail($file),
new NotifyUser($user),
])->dispatch();
Job Batching
Dispatch a group of jobs and run callbacks when they all complete (or any fail):
$batch = Bus::batch([
new ImportRow($row1),
new ImportRow($row2),
new ImportRow($row3),
])->then(function (Batch $batch) {
// All jobs succeeded
})->catch(function (Batch $batch, \Throwable $e) {
// First failure
})->finally(function (Batch $batch) {
// Batch complete regardless of outcome
})->dispatch();
This is especially useful when building AI-assisted workflows — batch-process documents for embedding, send all the chunks to an LLM, then aggregate results when the batch finishes. It’s a natural fit for that kind of work.
Rate Limiting Jobs
Prevent overwhelming an external API by rate-limiting how fast your jobs execute:
public function handle(): void
{
Redis::throttle('openai-api')
->allow(60)
->every(60)
->then(function () {
// Do the API call
}, function () {
$this->release(10); // Try again in 10 seconds
});
}
The Practical Takeaway From This Laravel Queues Tutorial
Here’s the decision framework I actually use: if a task takes more than ~200ms or depends on an external service, it belongs in a queue. Email, file processing, webhook delivery, API calls, AI inference requests — all of it. Why would you make a user wait on any of that?
Start with the database driver locally, Redis in production, and Supervisor (or Horizon) keeping your workers alive. Set $tries and $timeout on every job — don’t skip this. Implement failed() for anything that matters. Add queue:restart to your deployment pipeline and don’t touch it again.
The shift in how your app feels to users is immediate. They click submit, they get a response, the work happens quietly in the background. That’s the version of your app worth shipping.




Leave a Reply