Queues make Laravel requests faster, but moving work into the background does not automatically make a system reliable. In production, workers can stop mid-job, external APIs can time out, and the same job can be delivered again. A sound design explicitly controls retries, timeouts, idempotency, resources, and observability.
What belongs on a queue?
Queues fit work that does not need to finish before the response: sending email, generating reports, processing images, synchronizing data, delivering webhooks, or importing large files. Operations that directly determine the result a user is waiting for, such as authorization or inventory reservation, often remain synchronous or need a dedicated business workflow.
A queue is an asynchronous processing boundary, not a place to hide every slow code path.
Choose Redis and install Laravel Horizon
Horizon provides a dashboard and worker configuration for Redis-powered Laravel queues. Current Laravel documentation requires Horizon to use Redis and notes that Horizon is not currently compatible with Redis Cluster.
composer require laravel/horizon
php artisan horizon:install
php artisan migrate
Configure the production connection:
QUEUE_CONNECTION=redis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
Do not expose Redis directly to the Internet. Restrict network access, enable authentication where appropriate, monitor memory, and choose a persistence policy that reflects how much job loss the system can tolerate.
Give every job explicit limits
A production job should define its attempt limit, runtime limit, and delay between retries. An array backoff increases the delay while a dependency is temporarily unhealthy.
<?php
namespace App\Jobs;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Throwable;
class SyncInvoice implements ShouldQueue
{
use Queueable;
public int $tries = 5;
public int $timeout = 90;
public bool $failOnTimeout = true;
public function __construct(public int $invoiceId) {}
public function backoff(): array
{
return [10, 30, 120, 300];
}
public function handle(): void
{
// Call the service with separate connection and request timeouts.
}
public function failed(?Throwable $exception): void
{
// Record context and alert; do not swallow the failure.
}
}
Retry transient faults such as timeouts, 429 responses, and 5xx errors. Validation errors, missing required data, or authorization failures usually will not heal on another attempt; fail them deliberately and route them to the appropriate workflow.
Align the three timeout boundaries
A safe configuration generally follows this order:
job timeout < Horizon supervisor timeout < Redis retry_after
For example, use a 90-second job timeout, a 100-second Horizon timeout, and a 120-second retry_after. Horizon needs time to terminate a stuck worker before the queue considers the job lost and delivers it again. If the worker timeout exceeds or sits too close to retry_after, two workers may process the same job.
At the process-manager layer, stopwaitsecs must exceed the longest valid job runtime so a deployment or restart does not kill useful work midway.
Dispatch only after the transaction commits
When a job depends on data written inside a transaction, a fast worker can run before commit and fail to find the record. Enable after_commit on the queue connection or specify it on dispatch:
DB::transaction(function () use ($order) {
$order->markAsPaid();
GenerateInvoice::dispatch($order->id)->afterCommit();
});
Pass IDs and minimal data to jobs. Serializing an Eloquent model with a large loaded relationship graph creates large payloads, preserves stale state, and puts more pressure on Redis.
Idempotency is the duplicate-processing defense
Queues commonly provide at-least-once delivery. A job can complete an external effect and then lose its acknowledgement when the worker stops, causing redelivery. Charging money, creating invoices, and sending webhooks must therefore tolerate repeated execution.
- Use a unique business key such as
invoice_id + action. - Enforce it with a database unique constraint, not only an application check.
- Commit state and business changes in one transaction when possible.
- Use a transactional outbox when coordinating database state with a message or external API.
- Use
ShouldBeUniqueto reduce duplicate dispatches, but do not treat it as an exactly-once guarantee.
Separate queues by priority and resource profile
Email, heavy media work, and payment-related jobs should not all compete in one queue. A practical split is high, default, notifications, and media. Define separate supervisors in config/horizon.php for workloads with different resource or priority needs.
'production' => [
'supervisor-critical' => [
'connection' => 'redis',
'queue' => ['high'],
'balance' => 'simple',
'processes' => 4,
'tries' => 3,
'timeout' => 100,
],
'supervisor-default' => [
'connection' => 'redis',
'queue' => ['default', 'notifications'],
'balance' => 'auto',
'minProcesses' => 2,
'maxProcesses' => 10,
'balanceMaxShift' => 1,
'balanceCooldown' => 3,
'tries' => 5,
'timeout' => 100,
'backoff' => [10, 30, 120],
],
],
auto allocates workers according to load, but it does not enforce strict queue priority. Give critical workloads dedicated supervisors, and set worker limits according to CPU, memory, database connections, and dependency rate limits.
Run Horizon as a managed service
Production needs Supervisor, systemd, or an equivalent process manager to restart Horizon after machine reboots and process failures. The managed command is:
php artisan horizon
The Horizon dashboard exposes operational information. Restrict it through the gate in HorizonServiceProvider, and place it behind HTTPS and application authentication.
Deploy without leaving workers on old code
Workers are long-lived processes and do not reload source code after each request. Laravel 13 can use php artisan reload during deployment to reload long-running services. For Horizon specifically, php artisan horizon:terminate asks current processes to exit gracefully after finishing their job, and the process manager starts fresh instances.
php artisan migrate --force
php artisan optimize
php artisan horizon:terminate
Do not deploy changes that are incompatible with queued payloads. When a constructor or payload format changes, support the old version during a transition window or drain the queue before release.
Operate failed jobs and observe the system
Operators should understand why a job failed before retrying it in bulk:
php artisan queue:failed
php artisan queue:retry <job-id>
php artisan queue:retry all
php artisan horizon:forget <job-id>
Dashboards and alerts should track queue wait time, oldest-job age, throughput, p95/p99 runtime, retries, failed jobs, worker restarts, Redis memory, and dependency failures. Schedule Horizon snapshots and prune metrics and failed-job data according to an explicit retention policy.
Test before production
- Use
Queue::fake()to assert that a job is dispatched to the right queue with the right data. - Test
handle()directly for transient and permanent failures. - Run integration tests with real Redis and workers for critical paths.
- Simulate a worker stopping after a business effect but before acknowledgement.
- Run concurrent jobs against the same business key.
- Deploy while the queue is active and confirm new workers use the intended code version.
Production checklist
- Redis is reachable only from trusted networks and its memory is monitored.
- Every job has finite timeouts, attempts, and backoff.
- Timeouts follow
job < Horizon < retry_after. - Jobs that depend on transactions are dispatched after commit.
- Critical business effects use idempotency keys and unique constraints.
- Heavy or critical queues have dedicated supervisors.
- Horizon runs under a process monitor and its dashboard is protected.
- Deployment gracefully reloads or terminates workers.
- Failed jobs have alerts, ownership, and a controlled replay process.
Conclusion
A reliable Laravel queue is not defined by worker count but by explicit boundaries: retries do not duplicate business effects, workers restart without losing work, and operators can see where latency is building. Redis and Horizon provide a strong foundation; safety comes from aligned timeouts, idempotency, workload isolation, and a controlled deployment process.




No comments yet. Be the first to share your thoughts.