Laravel queues feel easy in development. You set QUEUE_CONNECTION, run php artisan queue:work in a terminal, and emails send, PDFs generate and webhooks fire in the background. Then you deploy, close the terminal, and a week later a customer asks why their order confirmation never arrived.
I'm Muhammad Ali Mughal, and at Loop Origin, our Karachi team builds and maintains Laravel applications, including e-commerce stores that rely on queues for order emails, courier bookings, SMS and payment callbacks. This is the production setup we use, and the mistakes it's designed to prevent.
1. Pick a real queue driver
The sync driver runs jobs immediately inside the request, which is fine for tests but defeats the point in production. The database driver works and is a reasonable start for small apps. For anything busy, I use Redis: it's fast, it's what Laravel Horizon is built on, and it keeps queue traffic off your main database.
In .env:
QUEUE_CONNECTION=redis REDIS_HOST=127.0.0.1 REDIS_PORT=6379 If Redis also holds your cache and sessions, consider a separate Redis database or connection for queues, so clearing the cache never wipes pending jobs.
2. Split work into named queues
Not all jobs are equally urgent. A password reset email shouldn't wait behind a 5,000-row export. Dispatch onto named queues:
SendOrderConfirmation::dispatch($order)->onQueue('high'); GenerateMonthlyReport::dispatch($month)->onQueue('low'); Then tell the worker to process them in priority order:
php artisan queue:work redis --queue=high,default,low The worker always empties high before looking at default, and default before low.
3. Keep workers alive with Supervisor
queue:work is a long-running process. If it crashes, runs out of memory or the server reboots, nothing restarts it unless you have a process monitor. On Ubuntu, Supervisor is the standard choice:
sudo apt install supervisor Create /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 redis --queue=high,default,low --sleep=3 --tries=3 --max-time=3600 autostart=true autorestart=true stopasgroup=true killasgroup=true user=www-data numprocs=2 redirect_stderr=true stdout_logfile=/var/www/app/storage/logs/worker.log stopwaitsecs=3600 Load it:
sudo supervisorctl reread sudo supervisorctl update sudo supervisorctl start "laravel-worker:*" A few notes on those options:
-
numprocsis how many workers run in parallel. Start with two and increase only if the queue backs up; each worker holds your whole app in memory. -
--max-time=3600makes each worker exit after an hour so Supervisor starts a fresh one. This limits slow memory leaks.--max-jobsdoes the same by job count, and--memorysets a memory ceiling. -
stopwaitsecsshould be longer than your longest job, so Supervisor doesn't kill a worker mid-job during a stop. -
usershould match the user that owns your app files, or you'll get permission errors on logs and storage.
4. Restart workers on every deploy
This one catches almost everyone. A running worker has already loaded your code into memory. Deploy new code and the workers keep running the old version until they restart.
Add this to the end of your deploy script:
php artisan queue:restart It signals workers to finish their current job and exit gracefully; Supervisor then starts them again with the new code. Clear and rebuild config cache first (php artisan config:cache) so the new workers pick up the right settings.
5. Set timeouts and retries deliberately
Three settings work together, and getting them out of sync causes jobs to run twice:
- Job
$timeout: the maximum seconds a job may run before the worker kills it. -
retry_afterinconfig/queue.php: how long the queue waits before assuming a job died and handing it to another worker. -
$tries/$backoff: how many attempts, and how long to wait between them.
Rule: retry_after must be longer than your longest job timeout. If a job legitimately takes 120 seconds and retry_after is 90, a second worker picks it up while the first is still running.
On the job class:
class BookCourierShipment implements ShouldQueue { use Queueable; public int $tries = 5; public int $timeout = 60; public function __construct(public Order $order) {} public function backoff(): array { return [10, 60, 300]; // seconds before retries 1, 2, 3+ } public function handle(CourierClient $courier): void { $courier->book($this->order); } } Increasing backoff matters for third-party APIs. If a courier or SMS gateway is down, hammering it every three seconds won't help, and may get you rate-limited.
6. Handle failed jobs instead of losing them
When a job runs out of attempts, Laravel records it in the failed_jobs table (recent Laravel versions include that migration by default). Learn these commands:
php artisan queue:failed # list failed jobs php artisan queue:retry <uuid> # retry one php artisan queue:retry all # retry all php artisan queue:forget <uuid> # delete one php artisan queue:prune-failed --hours=168 Add a failed() method to important jobs so someone actually finds out:
public function failed(?Throwable $e): void { Log::error('Courier booking failed', [ 'order' => $this->order->id, 'error' => $e?->getMessage(), ]); // notify the ops team by email or Slack } Schedule the prune command so the table doesn't grow forever.
7. Make jobs safe to run twice
Even with correct settings, a job can occasionally run more than once: a worker crashes after doing the work but before acknowledging it, or someone retries a failed job that half-succeeded. Design for it:
- Check before acting. Before booking a shipment, check whether the order already has a tracking number.
- Use unique jobs. Implement
ShouldBeUniqueto stop duplicate jobs for the same model from being queued at the same time. - Prevent overlap. The
WithoutOverlappingjob middleware stops two jobs for the same key from running simultaneously. - Dispatch after commit. If you dispatch inside a database transaction, use
->afterCommit()(or setafter_commiton the connection) so the worker doesn't look for a record that isn't saved yet.
This matters most for payment callbacks and inventory updates, where a duplicate means a double charge or a wrong stock count.
8. Watch the queue
You can't fix what you can't see. Options, from simplest:
- Tail
storage/logs/worker.logand checksupervisorctl statusafter deploys. - Schedule
php artisan queue:monitor redis:high,redis:default --max=100so Laravel fires an event when a queue grows past a threshold, and send yourself an alert from it. - Install Laravel Horizon if you're on Redis. It gives you a dashboard of throughput, wait times and failed jobs, and manages worker processes from a config file. With Horizon, Supervisor runs
php artisan horizoninstead ofqueue:work, and you restart withphp artisan horizon:terminateon deploy.
Production checklist
- Redis (or database) driver, never
sync - Named queues with priority order
- Supervisor running workers with
--max-timeor--max-jobs -
queue:restart(orhorizon:terminate) in every deploy -
retry_afterlonger than the longest job$timeout -
$triesand increasingbackoffon jobs that call external APIs -
failed()handlers and a scheduled prune - Idempotent jobs for payments, stock and shipments
- Queue-length alerts
If you'd like a team to build or review this for your app, our Laravel development services cover queued jobs, integrations and deployment. You can see our other work at looporigin.com, or find us on Google.
What's the worst queue bug you've hit in production? Tell me in the comments.


