Profile    Mohammed Shiroz Status   Loading  
Logo
Share This
Back to blog
Filter by:
Tags
//Article title

Laravel Queues: A Practical Guide to Background Jobs That Don't Bite

About Post

A tenant pays their rent. Your controller saves the payment, generates a PDF receipt, emails it, sends a push notification and updates an accounting system. The user watches a spinner the whole time.

Then the mail server has a bad moment, the request throws an exception, and the user sees an error page. For a payment that actually went through.

That's the case for queues in two sentences: users shouldn't wait for work they don't need to see, and a slow or flaky side effect shouldn't break the main action. Laravel makes queues easy to start with. Making them reliable takes a few more decisions, and those are what this guide is about. Examples are for Laravel 11.

The basic shape

Save the important thing in the request, and push everything else to a job:

// In the controller: fast and focused
$payment = Payment::create($validated);

SendPaymentReceipt::dispatch($payment);

return response()->json($payment, 201);

A worker process picks the job up a moment later and does the slow part. If it fails, it can try again without the user ever knowing.

One trap right away: if you dispatch inside a database transaction, the worker can pick the job up before the transaction commits, and find no payment. Use ->afterCommit() on the dispatch (or set after_commit on the connection) so the job only goes out once the data really exists.

Choosing a driver

DriverGood forWatch out for
syncTests and local debuggingRuns jobs immediately, in the request. Not a queue at all.
databaseSmall to medium apps, zero extra servicesHeavy job volume adds load to your main database.
redisMost production appsAnother service to run and monitor. Pairs well with Horizon.
sqsAWS setups, no queue server to manageMessage size limits and its own retry semantics.

Laravel 11 ships with the database driver by default, and honestly that's a perfectly fine place to start. Move to Redis when volume or latency asks for it, not before.

A job that's ready for real life

class SendPaymentReceipt implements ShouldQueue
{
    use Queueable;

    public $tries = 5;
    public $timeout = 60;
    public $backoff = [10, 60, 300];

    public function __construct(public Payment $payment) {}

    public function handle(ReceiptMailer $mailer): void
    {
        if ($this->payment->receipt_sent_at) {
            return; // already done on an earlier attempt
        }

        $mailer->send($this->payment);
        $this->payment->update(['receipt_sent_at' => now()]);
    }

    public function failed(?Throwable $e): void
    {
        Log::error('Receipt failed', ['payment' => $this->payment->id]);
    }
}

A few things are doing real work here. Passing the model in the constructor serializes only its ID, and the worker fetches a fresh copy from the database, so the job always sees current data. $backoff spaces out the retries: 10 seconds, then a minute, then five minutes for the rest, which gives a struggling mail server room to recover instead of hammering it. And failed() runs once all attempts are used up, which is where you alert someone.

Retries mean jobs run more than once

This is the part people skip. If a job can retry, it will eventually run twice for the same data: it might send the email and then crash before marking it sent. Or a worker might get killed mid-job and the job gets released again.

So every job should be safe to run more than once. That's what the receipt_sent_at check above is doing. The same idea applies everywhere:

  • Check whether the work is already done before doing it.
  • Use unique constraints so a duplicate insert fails loudly instead of creating a second row.
  • When calling payment gateways or other APIs that support it, send an idempotency key.

Laravel also offers ShouldBeUnique to stop duplicate jobs being queued at the same time, and the WithoutOverlapping job middleware to stop two jobs for the same record running in parallel. Both help, but neither replaces making the job itself safe to repeat.

Timeouts and the retry_after trap

There are two settings that sound alike and cause real trouble when they disagree:

  • $timeout (or --timeout on the worker): how long a job may run before the worker kills it.
  • retry_after in config/queue.php: how long the queue waits before assuming a job is stuck and handing it to another worker.

If retry_after is shorter than your job's run time, a slow but healthy job gets handed to a second worker while the first is still working on it. Now it runs twice, at the same time.

The rule: retry_after must always be a bit longer than your longest job timeout. If a job legitimately takes five minutes, give it its own queue and connection with a matching retry_after.

Failed jobs are a to-do list

When a job runs out of tries, Laravel stores it in the failed_jobs table. That table is not a graveyard, it's a to-do list. The commands you'll use:

php artisan queue:failed          # list them
php artisan queue:retry all       # retry after fixing the cause
php artisan queue:retry 5f2c...   # retry one by its UUID
php artisan queue:prune-failed --hours=168

Get alerted when failed jobs appear, through failed(), a log channel or Horizon, instead of discovering a week later that no receipts went out.

Keeping workers alive with Supervisor

php artisan queue:work is a long-running process. In production something needs to start it, restart it if it dies, and run several copies. On a Linux server, that's usually Supervisor:

[program:app-worker]
command=php /var/www/app/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600
process_name=%(program_name)s_%(process_num)02d
numprocs=2
autostart=true
autorestart=true
user=www-data
stopwaitsecs=3600
redirect_stderr=true
stdout_logfile=/var/www/app/storage/logs/worker.log

--max-time makes each worker exit cleanly after an hour so Supervisor starts a fresh one, which keeps slow memory leaks in check. stopwaitsecs gives a running job time to finish when the worker is stopped. A $tries property on the job class wins over the --tries flag, so the flag is just the default for jobs that don't set their own.

And the one everybody forgets: workers keep your old code in memory. After every deploy, run php artisan queue:restart, or your workers will happily keep running last week's code.

The checklist

  • Save the critical thing in the request; queue the slow side effects.
  • Dispatch after the transaction commits.
  • Set $tries, $backoff and $timeout on purpose, not by default.
  • Make every job safe to run twice.
  • Keep retry_after longer than your longest timeout.
  • Watch failed_jobs and get alerted.
  • Run workers under Supervisor and restart them on every deploy.

The Laravel queue docs cover every option in detail, and they're worth a full read once you've got the basics running.

Which queue lesson did you learn the hard way: the forgotten queue:restart, the email sent three times, or something stranger?

Comments (0)
Leave your review

Thanks for your valuable comments. Your comments has been updated and appreciate your getting in touch...

01. About Shiroz

Mohammed Shiroz

Hi, I'm Mohammed Shiroz, a software engineer and AI enthusiast from Sri Lanka who turns ideas into intelligent, real-world solutions. With over 9 years of hands-on experience, I currently lead real estate ERP development at Kate Group, a...

03.My Projects

04. Categories

Ready To order Your Project ?

Get in Touch
Close