Laravel Queues and Jobs: Running Slow Work in the Background

Laravel Queues and Jobs: Running Slow Work in the Background


A customer places an order and your app needs to send a confirmation email, generate a PDF invoice, notify the warehouse and sync the sale to an accounting system. If all of that runs while the customer waits, the checkout spinner turns for several seconds, and if the accounting API is slow or down, the whole order can fail. That’s a poor experience and a fragile design.

Laravel’s answer is queues. You record the slow work as a job, respond to the user immediately, and let a background worker process the job moments later, retrying automatically if something fails. In this guide I’ll show how queues work, how to write and dispatch jobs, how to handle retries and failures properly, how to batch and chain work, and how to keep workers running reliably in production.

Why queues make apps faster and more reliable

Compare the same checkout handled two ways. The timings below are illustrative, but the shape is what matters: with a queue, the user’s wait no longer depends on how slow your email provider or PDF generator happens to be today.

Illustrative timeline comparing a synchronous request with a queued one. Synchronously, the user waits for saving the order, sending the email, generating the PDF and syncing to accounting, about 4.4 seconds in total. With a queue, the request saves the order and dispatches jobs, responding in about 0.2 seconds, while a background worker sends the email, generates the PDF and syncs accounting afterwards.0 s1 s2 s3 s4 sWithouta queueSend emailGenerate PDFSync accountingUser waits ~4.4 sWith aqueueResponse in ~0.2 sWorker(background)Send emailGenerate PDFSync accountingSave orderDispatch jobsSlow work (illustrative timings)
Figure 1: The same work, moved out of the request. The user gets an instant response; the worker does the slow parts moments later.

Speed is only half the benefit. Queued jobs are retried when they fail, so a temporary outage at your email provider delays an email by a minute instead of losing it. And because work is spread over workers, a sudden spike of orders doesn’t overload your web server.

How Laravel queues work

Queue architecture: the web application dispatches a serialized job into a queue stored in the database or Redis. Worker processes, kept alive by Supervisor, pull jobs and run them. A job that succeeds is removed; one that throws is released back to the queue with a delay until its tries run out, then it is stored in the failed_jobs table and its failed method is called.Web appJob::dispatch()Queuedatabase or RedisWorker 1Worker 2Worker 3Successjob removedfailed_jobsafter last tryException: released back with a delay (backoff) and retriedkept alive by Supervisor
Figure 2: Dispatch, queue, workers, and what happens on success or failure.

When you dispatch a job, Laravel serializes it (its class and the data it needs) and stores it in a queue. A worker, a long-running php artisan queue:work process, pulls jobs off the queue and runs their handle() method. If handle() finishes, the job is deleted. If it throws an exception, the job goes back on the queue to be retried later, until it runs out of attempts and lands in the failed_jobs table.

Choosing a queue driver

The queue’s storage is set by QUEUE_CONNECTION in .env. Recent Laravel versions default to database and ship the migration for the jobs, job_batches and failed_jobs tables, so queues work out of the box with MySQL.

DriverHow it worksGood for
syncRuns the job immediately, inside the requestLocal testing and debugging only
databaseStores jobs in a MySQL tableMost small and medium apps; no extra services needed
redisStores jobs in Redis, in memoryHigh volume; pairs with Laravel Horizon for a monitoring dashboard
Start with the database driver; move to Redis when volume demands it.

Writing and dispatching a job

Generate a job class with Artisan:

php artisan make:job SendOrderToAccounting
<?php
// app/Jobs/SendOrderToAccounting.php

namespace App\Jobs;

use App\Models\Order;
use App\Services\AccountingClient;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;

class SendOrderToAccounting implements ShouldQueue
{
    use Queueable;

    public function __construct(public Order $order)
    {
    }

    public function handle(AccountingClient $accounting): void
    {
        // Dependencies are injected into handle() by the service container.
        $invoiceId = $accounting->createInvoice($this->order);

        $this->order->update(['accounting_invoice_id' => $invoiceId]);
    }
}

Notice that the job receives the whole Order model. Laravel stores only the model’s ID in the queue and fetches a fresh copy from the database when the job runs, so the payload stays small and the data is never stale. Dispatching it is one line:

// In a controller, after the order is saved
SendOrderToAccounting::dispatch($order);

// Run it on a specific queue, a few minutes later
SendOrderToAccounting::dispatch($order)
    ->onQueue('integrations')
    ->delay(now()->addMinutes(5));

// Only dispatch once the surrounding database transaction has committed
SendOrderToAccounting::dispatch($order)->afterCommit();

That last option prevents a classic bug: a worker picks up the job before the transaction that created the order has committed, can’t find the order, and fails. Emails get the same treatment for free: any Mailable that implements ShouldQueue, or any Mail::to($user)->queue(...) call, is sent by a worker instead of during the request.

Retries, backoff and failed jobs

External services fail. A well-written job expects it. Tell Laravel how many attempts to make, how long to wait between them, and what to do if every attempt fails:

<?php
use App\Notifications\AccountingSyncFailed;
use Illuminate\Support\Facades\Notification;

class SendOrderToAccounting implements ShouldQueue
{
    use Queueable;

    public int $tries = 4;

    public int $timeout = 120; // Seconds before the worker kills a stuck job.

    // Wait 10 seconds, then 1 minute, then 5 minutes between attempts.
    public array $backoff = [10, 60, 300];

    public function __construct(public Order $order)
    {
    }

    public function handle(AccountingClient $accounting): void
    {
        // ...
    }

    public function failed(?\Throwable $exception): void
    {
        // Every attempt failed: tell a human.
        Notification::route('mail', 'accounts@example.com')
            ->notify(new AccountingSyncFailed($this->order, $exception));
    }
}
Retry timeline for a job with four tries and backoff of 10, 60 and 300 seconds: attempt 1 fails, wait 10 seconds, attempt 2 fails, wait 60 seconds, attempt 3 fails, wait 300 seconds, attempt 4 either succeeds or the job moves to failed_jobs and the failed method runs.1234Attempt 1failsAttempt 2failsAttempt 3failsLast attemptwait 10 swait 60 swait 300 sSuccess, or failed_jobs + failed()
Figure 3: Growing gaps between attempts give a struggling service time to recover.

Failed jobs stay in the database until you deal with them. These commands are part of my routine after any incident:

php artisan queue:failed              # List failed jobs with their errors
php artisan queue:retry all           # Push them all back onto the queue
php artisan queue:retry 5a8f...       # Retry one by its ID
php artisan queue:forget 5a8f...      # Delete one
php artisan queue:prune-failed --hours=168   # Clean up entries older than a week

Make jobs safe to run twice

A job can run more than once: a worker might crash after the work was done but before the job was deleted. Design jobs to be idempotent. Check whether the invoice already exists before creating it, use updateOrCreate() instead of create(), and pass an idempotency key to payment APIs that support one.

Preventing duplicates and overload

Two more tools keep jobs well-behaved. Implementing ShouldBeUnique stops the same job being queued twice while one is still waiting, which is useful when a “sync this product” button gets clicked five times. Job middleware controls how jobs run, for example never processing two jobs for the same order at once, or respecting a third-party API’s rate limit:

<?php
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Queue\Middleware\WithoutOverlapping;

class SyncProductStock implements ShouldQueue, ShouldBeUnique
{
    use Queueable;

    public function __construct(public Product $product)
    {
    }

    // One pending job per product.
    public function uniqueId(): string
    {
        return (string) $this->product->id;
    }

    public function middleware(): array
    {
        return [
            new WithoutOverlapping($this->product->id), // Never run two at once for this product.
            new RateLimited('supplier-api'),           // Uses a RateLimiter::for('supplier-api') definition.
        ];
    }
}

Chains and batches

Some work has to happen in order; other work can happen all at once. Laravel handles both.

Chains: one after another

In a chain, each job only runs if the previous one succeeded. If one fails, the rest are skipped:

use Illuminate\Support\Facades\Bus;

Bus::chain([
    new GenerateInvoicePdf($order),
    new EmailInvoiceToCustomer($order),
    new SendOrderToAccounting($order),
])->dispatch();

Batches: many at once, with progress

Batches are perfect for imports. Split a large CSV into chunks, process the chunks in parallel across your workers, and get callbacks when everything finishes. Jobs in a batch use the Batchable trait:

<?php
// app/Jobs/ImportProductChunk.php
use Illuminate\Bus\Batchable;

class ImportProductChunk implements ShouldQueue
{
    use Batchable, Queueable;

    public function __construct(public array $rows)
    {
    }

    public function handle(): void
    {
        if ($this->batch()?->cancelled()) {
            return;
        }

        foreach ($this->rows as $row) {
            Product::updateOrCreate(
                ['sku' => $row['sku']],
                ['name' => $row['name'], 'price' => $row['price']],
            );
        }
    }
}

// Dispatching the import
use Illuminate\Bus\Batch;

$jobs = collect($rows)->chunk(500)
    ->map(fn ($chunk) => new ImportProductChunk($chunk->values()->all()));

$batch = Bus::batch($jobs)
    ->name('Product import')
    ->then(fn (Batch $batch) => $import->markComplete())
    ->catch(fn (Batch $batch, \Throwable $e) => report($e))
    ->dispatch();

// Later, for a progress bar
Bus::findBatch($batch->id)->progress(); // 0 to 100

Testing queued work

You don’t want tests sending real emails or calling a real accounting API. Laravel’s fakes record what would have been dispatched so you can assert on it, and you can test the job’s own logic separately by calling handle() directly:

<?php
use App\Jobs\SendOrderToAccounting;
use App\Models\Order;
use Illuminate\Support\Facades\Queue;

it('queues the accounting sync when an order is placed', function () {
    Queue::fake();

    $this->post('/checkout', [/* valid checkout data */])->assertRedirect();

    Queue::assertPushed(SendOrderToAccounting::class, function ($job) {
        return $job->order->status === 'paid';
    });
});

it('stores the invoice id returned by the accounting service', function () {
    $order = Order::factory()->create();

    $client = Mockery::mock(\App\Services\AccountingClient::class);
    $client->shouldReceive('createInvoice')->once()->andReturn('INV-1001');

    (new SendOrderToAccounting($order))->handle($client);

    expect($order->fresh()->accounting_invoice_id)->toBe('INV-1001');
});

Bus::fake() does the same for chains and batches, with Bus::assertChained() and Bus::assertBatched().

Running workers in production

Locally, php artisan queue:work in a terminal is enough. In production, workers must start automatically and restart if they crash. The standard tool on Linux servers is Supervisor. This configuration, adapted from the Laravel documentation, keeps four workers running:

# /etc/supervisor/conf.d/laravel-worker.conf
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/artisan queue:work --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/app/storage/logs/worker.log
stopwaitsecs=3600
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start "laravel-worker:*"

Three details matter more than they look:

  • Workers keep your code in memory. After every deployment, run php artisan queue:restart so workers finish their current job and reload the new code. Forget this and workers keep running last week’s version.
  • --max-time recycles workers every hour, which keeps memory leaks from building up.
  • stopwaitsecs should be longer than your longest job, so Supervisor doesn’t kill a worker in the middle of an import.

I include these steps in every deployment checklist; see deploying Laravel to production for the full list. If you use the Redis driver, Laravel Horizon replaces the hand-written worker configuration with a dashboard showing throughput, wait times and failed jobs.

Real-world use cases

Once you start thinking in jobs, you’ll see candidates everywhere. These are the ones I queue in almost every project:

  • Emails and notifications: order confirmations, password resets, admin alerts.
  • Incoming webhooks: verify the signature, store the payload, dispatch a job and return 200 within milliseconds. Payment and shipping providers retry webhooks that respond slowly, which can cause duplicate processing.
  • Imports and exports: CSV product imports in batches, large reports generated in the background and emailed as a download link.
  • Syncing with other systems: pushing orders to accounting, stock levels to a WooCommerce or Shopify store, contacts to a CRM. My guide on syncing with the WooCommerce REST API shows the other end of such an integration.
  • Media processing: resizing uploaded images and generating PDFs.

Key takeaways

  • Anything slow or dependent on another service belongs in a queued job, not in the request.
  • The database driver works out of the box with MySQL; move to Redis and Horizon when volume grows.
  • Set $tries, $backoff and $timeout, implement failed(), and make jobs idempotent.
  • Use chains for ordered steps and batches for large parallel work like imports.
  • Run workers under Supervisor and always queue:restart after deploying.

Queues are often the difference between an app that feels instant and one that times out under load. If your Laravel application has slow pages, unreliable integrations or imports that never finish, get in touch and I can help move that work into the background properly. If you’re just getting started, read building a REST API with Laravel and Sanctum next.

Code examples in this post are free to use under the MIT licence. Article text © 2026 Farooq Bin Munir.