A client once told us their checkout page "sometimes just spins". Nothing was down. The Laravel app was doing too much work inside the request: sending an email, generating a PDF invoice, calling a courier API, and pushing a row to an accounting tool, all before the customer saw a confirmation. When the courier API was slow, the customer waited.
The fix is almost always the same: move the slow work out of the request and into a queue. It sounds simple, and the first version usually is. The trouble shows up three months later, when the queue has quietly stopped and nobody noticed. This post is about how we set queues up so that does not happen.
What belongs in a queue
We ask one question about each step in a request: does the customer need the result of this before they can continue?
- Saving the order: yes, keep it in the request.
- Taking the payment: yes, in the request.
- Confirmation email, PDF invoice, courier booking, accounting sync, search index update, image resizing: no. Queue these.
The same rule applies to AI features. If an assistant summarises an uploaded document or tags a support ticket, the user rarely needs to stare at a spinner for it. Queue the job, show a status, and update the screen when it finishes. Model calls are slow and sometimes fail, which makes them a natural fit for jobs with retries.
Pick the driver for the size of the project
Laravel supports several queue drivers. Our usual choices:
- Database driver for small sites with modest traffic, such as a business site with order forms. It needs no extra service, and the jobs table is easy to inspect with a plain SQL query. It is enough more often than people expect.
- Redis with Horizon once volume grows, or when different kinds of work need separate lanes and visible dashboards. Horizon shows throughput, wait times and failed jobs without us building anything.
- Amazon SQS when the client already runs on AWS and wants the queue managed for them.
We would rather start a client on the database driver and move later than put Redis in front of a site that sends forty emails a day. Changing the driver is a config change as long as the jobs themselves are written well.
Write jobs that are safe to run twice
Queues deliver "at least once". A worker can crash after the courier booking succeeded but before the job was marked done, and the job will run again. If the job is not written for that, the client ends up with two parcels.
Our habits:
- Pass IDs, not whole objects. Send the order ID and load fresh data inside the job. Otherwise the job may act on stale data that sat in the queue for ten minutes.
- Check before acting. Store the courier reference on the order. If it already exists, the job exits early.
- Use idempotency keys with any payment or booking API that supports them.
- Set
tries,backoffandtimeouton purpose. A job that calls an external API should retry a few times with growing delays, not hammer the API ten times in ten seconds. Laravel'sbackoffproperty takes an array for this. - Use
ShouldBeUniqueorWithoutOverlappingwhen two copies of the same job running together would cause damage, such as two syncs of the same customer record.
Failures need an owner
Laravel keeps failed jobs in a failed_jobs table. That table is where problems go to be forgotten. For each client we agree on three things up front:
- Which failures alert someone, and who that is. A failed invoice email can wait until morning. A failed payment webhook cannot.
- Where the alert goes. Usually a message in a channel the client already reads, not an inbox no one opens.
- What "retry" means. Some jobs are safe to push again with
php artisan queue:retry. Others need a person to look at the order first. We note this in the handover document, job by job.
We also add a simple heartbeat: a scheduled task writes a timestamp every minute, and a separate check alerts if the timestamp is more than a few minutes old, or if the oldest waiting job is too old. This catches the case where the worker process died and nothing is failing, because nothing is running at all.
Keep the workers running
On the server, the worker has to be supervised. We use Supervisor on VPS hosting, or systemd units where that is simpler. Two details people miss:
- Workers keep the application code in memory. After every deploy, run
php artisan queue:restartso they pick up the new code. We put it in the deploy script so no one has to remember. - Set a memory limit and a maximum number of jobs per worker so a slow leak ends in a clean restart rather than a stalled server.
Shared hosting is the hard case. If the client's hosting does not allow long-running processes, a cron job running queue:work --stop-when-empty every minute works as a fallback. It is not elegant, but it is better than doing the work inside the request. In that situation we also tell the client plainly that moving to a small VPS would be a better long-term home.
What the customer notices
After this change the checkout confirmation appears in well under a second, whatever the courier API is doing. If the courier is down for an hour, orders still go through and the bookings catch up when it recovers. The client's team gets one alert about the delay instead of a dozen angry emails from customers.
When we would not queue something
Queues add moving parts. If a task takes fifty milliseconds and cannot fail in an interesting way, do it in the request. We also avoid queuing anything where the user needs an immediate, truthful answer, such as "is this coupon valid". Adding a queue for its own sake just gives the client one more thing to monitor.
If you have a slow Laravel app
Send us the page or action that feels slow and a rough idea of what happens when it runs. In most cases we can tell within a short call whether a queue is the answer, or whether the problem is a missing database index or an unbounded query. We would rather tell you that than sell you a bigger server.
Reference: Laravel's own documentation on queues and Horizon at https://laravel.com/docs/queues and https://laravel.com/docs/horizon covers the options mentioned here in detail.