Laravel Horizon helps operate Redis-backed background jobs: which queues are waiting, which jobs failed, and which workers are running. It cannot establish that a business operation succeeded merely because a job returned without an exception. Keep an observable result alongside the queue status.
This walkthrough targets Laravel 12 and uses an isolated catalogue-processing example: an import attempt produces a small background summary. The example is deliberately narrower than a catalogue importer. It does not describe monitoring deployed on a Nomadic Soft production system.
The executable checks for this article used PHP 8.3.6, Laravel 12.40.2, Horizon 5.50.0, Predis 3.6.1 and Redis 7.0.15. Those are the tested versions, not a claim that they are the latest releases.
Choose Horizon for queue operation, Pulse for investigation
The Laravel 12 Horizon documentation covers worker management and queue throughput, runtime and failures. Our companion Laravel Pulse guide examines application activity and performance. A slow catalogue page and a stalled background summary may be related, but they require different evidence.
Horizon requires a Redis queue connection; it does not manage a database or SQS queue. The referenced documentation also excludes Redis Cluster. Confirm the actual deployment topology rather than assuming every Redis-compatible endpoint is supported. Install the required PHP extensions and a supported Redis client, including the process-control capabilities required by workers and timeouts.
Keep the exercise isolated: use a disposable Redis instance or separate database and distinct application/Horizon prefixes. Do not run a second test worker against production queues. The snippets below assume an existing Laravel 12 application with Redis connectivity and a writable local storage disk.
composer require laravel/horizon:5.50.0
php artisan horizon:install
This installation pins Horizon to the exercise's package version; review dependency upgrades separately. Review the generated provider and configuration, and commit the resulting dependency lockfile when applying this to a project. Check the provider is registered. Keep dashboard access restricted while configuring it; installation is not an authorization review.
Connect the producer and supervisor to the same queue
For this local exercise, use QUEUE_CONNECTION=redis and set retry_after to 60 in the existing Redis connection inside config/queue.php. That configuration's connection value names the underlying Redis connection, usually default. A queue connection, a Redis connection and a queue name are different things.
Replace only the local entry inside config/horizon.php's environments array with this supervisor. It listens on the queue connection redis and the queue named reports. Other environments need their own reviewed settings.
// config/horizon.php: replace the local entry in environments.
'local' => [
'reports' => [
'connection' => 'redis',
'queue' => ['reports'],
'balance' => 'auto',
'autoScalingStrategy' => 'time',
'minProcesses' => 1,
'maxProcesses' => 2,
'balanceMaxShift' => 1,
'balanceCooldown' => 3,
'timeout' => 30,
'tries' => 3,
],
],
Clear stale configuration in the disposable application with php artisan config:clear, then start php artisan horizon in one terminal. Use php artisan horizon:status from another. A running process is the first check; the next is whether it consumes the intended queue.
Here, automatic balancing has a small one-to-two-worker range. These are example limits, not capacity recommendations. With multiple queues, automatic balancing is workload-driven, not strict priority by array order. Separate supervisors can reserve distinct worker budgets for interactive notifications and heavy reports; database and provider capacity still constrain both.
Dispatch a job with a result you can inspect
Create app/Jobs/WriteReportSnapshot.php with this complete class. It receives an immutable summary of an already-finished catalogue attempt. A restricted report key prevents arbitrary path selection; invalid keys fail immediately rather than repeatedly retrying a permanent input error.
<?php
namespace App\Jobs;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Storage;
use InvalidArgumentException;
use RuntimeException;
class WriteReportSnapshot implements ShouldQueue
{
use Queueable;
public int $timeout = 20;
public int $tries = 3;
public function __construct(
public string $reportKey,
public array $summary,
) {}
public function backoff(): array
{
return [2, 5];
}
public function handle(): void
{
if (! preg_match('/\A[a-z0-9_-]+\z/', $this->reportKey)) {
$this->fail(new InvalidArgumentException('Invalid report key.'));
return;
}
$written = Storage::disk('local')->put(
'reports/'.$this->reportKey.'.json',
json_encode($this->summary, JSON_THROW_ON_ERROR),
);
if (! $written) {
throw new RuntimeException('Report snapshot could not be written.');
}
}
}
The filesystem API can report a failed write by returning false, or throw when configured to do so. This job converts a false result into an exception too. Swallowing that failure would make the queue appear successful without producing the intended report.
Open php artisan tinker in the second terminal and dispatch the example. The connection and queue names deliberately match the supervisor:
App\Jobs\WriteReportSnapshot::dispatch(
'catalogue-demo',
['accepted' => 12, 'failed' => 1],
)->onConnection('redis')->onQueue('reports');
After processing, inspect the output from Tinker. It should contain accepted => 12 and failed => 1; those values describe the fictional catalogue batch, not a Horizon failure count. The batch had a partial outcome: 12 records were accepted and one failed. Writing that summary successfully does not turn the failed catalogue record into a success.
json_decode(
Illuminate\Support\Facades\Storage::disk('local')
->get('reports/catalogue-demo.json'),
true,
512,
JSON_THROW_ON_ERROR,
);
Dispatch the same immutable payload again and inspect the same path. The example overwrites one report rather than creating another filename. This demonstrates a narrow repeat-delivery behavior; it is not an atomic publication mechanism, a concurrency guarantee or an exactly-once execution guarantee. A production report consumed while being written needs an appropriate publication strategy, and multiple worker hosts need the intended shared storage arrangement.
Coordinate timeouts, attempts and side effects
The example uses a job timeout of 20 seconds, a Horizon supervisor timeout of 30 and a Redis queue retry_after of 60. The ordering is intentional: job timeout < Horizon timeout < retry_after, with a margin. The values are not suitable defaults for every workload.
Laravel's queue documentation explains timeout handling and the PCNTL requirement. A reserved job becoming available for retry while an old worker still runs can cause overlapping execution. Configure HTTP connection/request timeouts separately when adding network calls; a queue timeout is not a replacement for the client's controls.
The job permits three attempts and supplies two retry delays: two seconds before the second attempt, then five before the third. Its job-level attempt setting takes precedence over the supervisor's setting. Middleware releases and rate limiting can consume attempts, so inspect the complete job pipeline before choosing that budget.
Retries can repeat a side effect that succeeded before the worker failed to acknowledge completion. For a real catalogue integration, record a stable batch/item identity and reconcile what the destination accepted. Use provider-supported idempotency keys for remote writes where available. A local “done” flag or unique-job lock alone cannot make an external request exactly once.
Authorize the dashboard in a non-local environment
The generated Horizon provider contains the viewHorizon gate. Add 'viewer_ids' => [] at the top level of config/horizon.php, then use this method in the existing provider. The empty list denies everyone outside the local bypass; grant only explicitly approved user identifiers as strings, or use your application's established administrator permission.
// app/Providers/HorizonServiceProvider.php
// Keep the generated class; add these imports if absent.
use App\Models\User;
use Illuminate\Support\Facades\Gate;
// Replace the class's existing gate method.
protected function gate(): void
{
Gate::define('viewHorizon', function (User $user): bool {
return in_array(
(string) $user->getAuthIdentifier(),
config('horizon.viewer_ids', []),
true,
);
});
}
This assumes your application already authenticates users; the gate does not create a login flow. Test three requests in a non-local environment: anonymous, authenticated but unlisted, and explicitly allowed. The first two must be denied. Local access bypasses the normal gate, so a successful local dashboard request proves nothing about production authorization. Never set a public deployment to APP_ENV=local to obtain dashboard access.
Queue payloads and exception details can contain business data. Minimize what jobs serialize, exclude secrets, limit dashboard users and choose retention deliberately. Treat retry actions as operations that can change application state.
Diagnose the cause before retrying
| Symptom | Check first | Useful next action |
|---|---|---|
| Pending jobs never start | Producer connection/queue, active environment and supervisor configuration. | Compare the dispatched names with the running supervisor; check maintenance/pause state. |
| Job reports success, output missing | Unchecked storage/API results, wrong disk and worker host. | Check the actual business result and make failures observable. |
| Repeated timeout failures | Runtime distribution, blocked dependencies and timeout/retry ordering. | Bound external calls, divide excessive work or revise justified limits together. |
| Duplicate external changes | Remote response history, job attempts and operation identifiers. | Reconcile the destination before replaying; repair idempotency boundaries. |
| Backlog grows with more workers | Database contention, rate limits, memory and downstream capacity. | Measure the constrained dependency before raising concurrency again. |
| Old behavior after deployment | Worker process age, release path and cached configuration. | Restart the long-lived process through the deployment lifecycle and confirm its release. |
Read the exception and inspect partial results before retrying a failed job. Fixing a permission or configuration error may make a retry useful; repeating an invalid payload will not. Preserve identifiers and diagnostic evidence instead of clearing queues to make the dashboard look healthy.
For Horizon's historical metrics, add the documented snapshot schedule to routes/console.php. The application's scheduler must actually run; a schedule definition alone does not execute anything.
// routes/console.php, alongside your existing schedule definitions.
use Illuminate\Support\Facades\Schedule;
Schedule::command('horizon:snapshot')->everyFiveMinutes();
Restart workers as part of the release
Horizon is long-lived. A production process manager must run php artisan horizon and restart it after termination. During deployment, php artisan horizon:terminate lets current jobs finish and then stops the process so its replacement can load the new release. Running that command without a functioning restart mechanism can leave the queue idle.
Make the new release available before its worker starts. Keep job payloads and schema compatible while old jobs remain queued, retain required files on persistent storage, and give the process manager enough shutdown time for the longest permitted job. Check status and process a harmless diagnostic job after restart. A website rollback also needs compatible workers and queued payloads.
What the isolated checks demonstrated
We ran the exact job above through real Horizon workers and Redis. Two deliveries of the same payload produced the same report contents at the same path. A job sent to an unwatched queue remained pending. A forced filesystem failure produced attempts one, two and three before one failed-job record; an invalid report key failed on its first attempt. Horizon's status command returned success.
In the same isolated application with its environment set to production, the exact typed gate above and Horizon's authentication middleware returned 403 for a guest, 403 for an unlisted user and 200 for an allowed user. Those were middleware checks using actual application user models, not a browser login or dashboard inspection. Production process management, load behavior and the deployment sequence were not exercised by this fixture.
Our Laravel CI/CD guide connects this lifecycle to a tested application revision. For an existing application, our Laravel and PHP development service starts with the specific workflow and failure you need to understand.
