CCelestiallines.com
← Back to blog

Laravel Queues and Jobs: A Practical Guide

By Pawan Singh · Jun 2026

Why Queue Work at All

Anything that doesn't need to finish before you respond to the user — sending email, generating a PDF, calling a slow third-party API, resizing an uploaded image — should run in the background. Queuing it keeps response times fast and, just as importantly, makes the operation retryable if it fails, instead of a failed email send taking down the whole request that triggered it.

Create a Job

php artisan make:job SendWelcomeEmail
class SendWelcomeEmail implements ShouldQueue
{
    use Queueable;

    public function __construct(public User $user) {}

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

Anything passed into the constructor (here, $user) gets serialized when the job is queued and unserialized again when a worker picks it up — Laravel handles Eloquent model serialization specially, storing just the ID and re-fetching the fresh model when the job actually runs, rather than serializing the entire model state at dispatch time.

Dispatch It

SendWelcomeEmail::dispatch($user);

By default this runs synchronously unless you've configured a real queue driver — check QUEUE_CONNECTION in .env. Other useful dispatch variants: dispatch()->delay(now()->addMinutes(10)) to run later, or dispatch()->onQueue('emails') to route it to a named queue for separate worker scaling.

Choosing a Driver

  • database — simplest to set up, fine for low-to-medium volume. Run php artisan queue:table && php artisan migrate first to create the jobs table.
  • redis — faster than the database driver, supports delayed jobs and rate limiting natively without extra querying overhead. The right default for anything beyond light production traffic.
  • sqs — hand off durability and scaling to AWS if you're already in that ecosystem; good when you don't want to operate the queue infrastructure yourself.

Running the Worker

php artisan queue:work --tries=3

In production, run this under a process manager like Supervisor so it restarts automatically if it crashes, and restart it after every deploy (php artisan queue:restart) so workers pick up new code — a long-running worker process has your application's code loaded into memory from when it started, so a deploy without a restart runs old code until the worker eventually dies and Supervisor relaunches it.

Job Batching

When you need to run many related jobs and know when they've all finished — processing a bulk import row by row, for instance — use a batch instead of dispatching jobs individually with no coordination:

$batch = Bus::batch([
    new ProcessRow($row1),
    new ProcessRow($row2),
])->then(function (Batch $batch) {
    // all jobs completed successfully
})->catch(function (Batch $batch, Throwable $e) {
    // first job failure detected
})->dispatch();

Batches also expose progress ($batch->processedJobs(), $batch->progress()), which is what a "78% complete" import progress bar is usually built on.

Job Middleware and Rate Limiting

Jobs can have their own middleware, most commonly used to rate-limit calls to a third-party API a job depends on:

public function middleware(): array
{
    return [new RateLimited('third-party-api')];
}

This keeps rate-limit logic attached to the job itself rather than scattered across every place that might dispatch it.

Handling Failures

Failed jobs (after exhausting --tries) land in the failed_jobs table. Define a failed() method on the job to react — alert someone, log context, clean up partial state:

public function failed(Throwable $e): void
{
    Log::error('Welcome email failed', ['user' => $this->user->id, 'error' => $e->getMessage()]);
}

Retry all failed jobs once the underlying issue is fixed with php artisan queue:retry all, or a specific one by its UUID. Use public $backoff = [10, 60, 300]; on the job to space out retries with increasing delay, rather than hammering a struggling downstream service three times in immediate succession.

Common Issues

  • Jobs silently never run — the worker process isn't actually running (check with your process manager, not just assuming it survived a server reboot), or QUEUE_CONNECTION in .env is pointed at a different connection than what the worker is listening to.
  • Stale code after deploy — queue workers cache the booted application in memory; always run php artisan queue:restart as part of every deploy script, not just when you remember something changed.
  • A job keeps failing with a stale/missing model error — the underlying record was deleted between when the job was dispatched and when a worker finally picked it up; guard against this in handle() rather than assuming the model still exists.
  • Jobs "run twice" — usually a worker being killed mid-job without proper signal handling, so it's marked failed and retried even though the first attempt actually completed; make job logic idempotent (safe to run more than once) wherever practical, rather than relying on exactly-once delivery.

Need help designing a reliable background job pipeline in Laravel? Get in touch — I work as a freelance Laravel developer.

Comments