← All posts
LaravelPHPBackendPerformanceQueues

Laravel Jobs and Queues: Why Your App Is Slower Than It Should Be

Joshua Kaycee·12 March 2026·6 min read·
4 views
Laravel Jobs and Queues: Why Your App Is Slower Than It Should Be

Every Laravel developer hits the same wall eventually. Your app works fine locally. You deploy it, real users start using it, and suddenly requests that should take 200ms are taking 4 seconds. Users are waiting. Timeouts are appearing in your logs. You start questioning your career choices.

The problem, almost always, is that you're doing too much inside the request cycle.

What the request cycle is not for

When a user submits a form, your application has one job: acknowledge the request and respond. That's it. Anything that doesn't directly contribute to that response should not happen inside that request.

Sending a welcome email? Not part of the response.
Resizing an uploaded image? Not part of the response.
Notifying five Slack channels? Absolutely not part of the response.
Generating a PDF? Not even close.

Yet most Laravel codebases I've seen do all of these things synchronously, making the user wait for every single one of them before they see a success message.

This is what queues solve.

The mental model

Think of queues like a restaurant kitchen. When you place an order, the waiter doesn't disappear into the kitchen and make you stand at the counter watching your food get cooked. They take your order, hand you a receipt, and tell you it'll be ready shortly. The kitchen handles the work independently.

Your application should work the same way. The HTTP request is the waiter. The queue is the kitchen. The worker is the chef.

Setting up queues in Laravel

Laravel ships with queue support built in. You don't install anything for the basics. Open your .env file and change one line:

env
QUEUE_CONNECTION=database

Then create the jobs table:

bash
php artisan queue:table
php artisan migrate

That's your queue infrastructure. Now let's put something on it.

Creating your first job

Say you're sending a welcome email when a user registers. The standard approach looks like this and it's wrong:

php
// UserController.php - the slow way
public function register(Request $request)
{
    $user = User::create($request->validated());
    
    // This blocks the response until the email is sent
    Mail::to($user->email)->send(new WelcomeMail($user));
    
    return response()->json(['message' => 'Account created']);
}

If your mail server is slow, the user waits. If it times out, the user gets an error even though their account was created successfully.

Here's the right way. First, create a job:

bash
php artisan make:job SendWelcomeEmail

This generates app/Jobs/SendWelcomeEmail.php. Fill it in:

php
<?php

namespace App\Jobs;

use App\Mail\WelcomeMail;
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;
use Illuminate\Support\Facades\Mail;

class SendWelcomeEmail implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 3;
    public int $backoff = 60; // seconds between retries

    public function __construct(public User $user) {}

    public function handle(): void
    {
        Mail::to($this->user->email)->send(new WelcomeMail($this->user));
    }

    public function failed(\Throwable $exception): void
    {
        // Log it, notify yourself, whatever makes sense
        \Log::error("WelcomeEmail failed for user {$this->user->id}: {$exception->getMessage()}");
    }
}

Now update your controller:

php
// UserController.php - the right way
public function register(Request $request)
{
    $user = User::create($request->validated());
    
    // Pushes to queue and returns immediately
    SendWelcomeEmail::dispatch($user);
    
    return response()->json(['message' => 'Account created']);
}

The response returns instantly. The email gets sent in the background by a queue worker, completely independently of the HTTP request.

The parts people get wrong

Not implementing the failed() method

When a job fails after all its retries, Laravel calls failed(). If you don't implement it, you lose visibility into what went wrong. Always implement it. At minimum, log the error. Better, send yourself a notification.

Putting too much data in the constructor

Laravel serializes the job to store it in the queue. If you pass an entire Eloquent model, it serializes a fresh database lookup — not the model's current state. The SerializesModels trait handles this correctly for Eloquent models, but avoid passing large arrays or complex objects that aren't models.

php
// Bad - passes raw data that may be stale
public function __construct(public array $userData) {}

// Good - Laravel re-fetches the model fresh when the job runs
public function __construct(public User $user) {}

Running the queue worker manually in production

On your local machine, you run:

bash
php artisan queue:work

In production, this process needs to be managed by a process supervisor like Supervisor, or a platform service like Laravel Forge. If it dies, your queued jobs sit there unprocessed and nobody knows.

On a VPS with Supervisor, a basic config looks like this:

ini
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/yourapp/artisan queue:work --sleep=3 --tries=3
autostart=true
autorestart=true
numprocs=2
redirect_stderr=true
stdout_logfile=/var/www/yourapp/storage/logs/worker.log

This keeps two workers running at all times and restarts them if they crash.

Queues on shared hosting

If you're on shared hosting without process management, you have a few options. The simplest is to schedule the queue worker as a cron job:

php
// app/Console/Kernel.php
protected function schedule(Schedule $schedule): void
{
    $schedule->command('queue:work --stop-when-empty')->everyMinute();
}

This runs the worker every minute, processes everything in the queue, and exits. It's not as efficient as a persistent worker but it works without Supervisor.

What to queue in a typical Laravel app

Once you understand queues, you'll start seeing opportunities everywhere. In a typical application, these should always be queued:

  • All outbound emails and SMS
  • Image processing and thumbnail generation
  • Third-party API calls (Stripe webhooks, Slack notifications)
  • PDF or report generation
  • Search index updates
  • Audit log writes that don't need to be synchronous
  • Bulk operations that process many database rows

A rough rule: if it touches a network or takes more than 100ms, queue it.

Monitoring your queues

Blind queues are dangerous queues. Laravel Horizon is the official dashboard for monitoring queues when using Redis. For the database driver, you can query the jobs and failed_jobs tables directly, or use a package like laravel-queue-monitor.

At minimum, set up an alert when the failed_jobs table gets a new row. That's the one metric that tells you something is broken.


Queues are one of those features that feel like extra complexity until you've been burned by a slow request cycle once. After that, you queue everything.

If you're building Laravel applications and want to talk architecture, performance, or anything full-stack, I write about it regularly at kebleinsyt.com.ng/blog.

Share on LinkedIn

Let your network read this post

JK

Joshua Kaycee

Full-stack developer · kebleinsyt.com.ng

Work with me