Laravel Horizon Job Failures Troubleshooting Guide

Avatar
C

Callum Davies

Senior Backend Engineer••
3Views
5mRead
0Reactions

Jobs are silently failing or hanging in Laravel Horizon on production, causing delayed processing and missed SLAs. The root cause is often a mis‑configured Redis connection, supervisor timeout, or an unhandled exception in the job payload. This guide shows how to reproduce the failure, validate your Horizon/Redis setup, adjust supervisor settings, and set up reliable alerts.

TL;DR / Quick Summary

  • Verify Redis connectivity (redis-cli ping).

  • Run the validation script (php scripts/horizon-check.php).

  • Ensure config/horizon.php has appropriate wait, sleep, and timeout values.

  • Update Docker supervisor config to increase stopwaitsecs.

  • Hook into JobFailed event to send Slack/Email alerts.

Reproduce a Failing Job

Example Job (app/Jobs/ProcessReport.php)

<?php

namespace App\Jobs;

use Illuminate\Bus\Batchable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Exception;

class ProcessReport implements ShouldQueue
{
    use InteractsWithQueue, SerializesModels, Batchable;

    public $tries = 3;
    public $timeout = 30; // seconds

    public function handle()
    {
        // Simulate a Redis timeout
        $client = redis()->connection();
        $client->set('test', str_repeat('x', 10_000_000)); // may exceed memory limit
        if (random_int(0, 1)) {
            throw new Exception('Random failure for debugging');
        }
    }
}

Dispatch the job from a route or tinker:

php artisan tinker
>>> \App\Jobs\ProcessReport::dispatch();

If Horizon shows the job as processing forever, the setup is broken.

Validate Horizon & Redis Settings

Validation Script (scripts/horizon-check.php)

<?php // scripts/horizon-check.php

require __DIR__.'/../vendor/autoload.php';

$app = require __DIR__.'/../bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();

use Illuminate\Support\Facades\Redis;
use Laravel\Horizon\Horizon;

function echoResult(string $label, bool $ok)
{
    echo $label . ': ' . ($ok ? "✅ OK" : "❌ FAIL") . PHP_EOL;
}

// 1. Redis connection
try {
    $pong = Redis::connection()->ping();
    echoResult('Redis ping', $pong === 'PONG');
} catch (Throwable $e) {
    echoResult('Redis ping', false);
    echo $e->getMessage() . PHP_EOL;
}

// 2. Horizon config sanity
$config = config('horizon');
$required = ['wait', 'sleep', 'trim.threshold'];
foreach ($required as $key) {
    echoResult("Horizon config $key", isset($config[$key]));
}

// 3. Supervisor process count
$processes = collect(Horizon::supervisorNames())
    ->map(fn($name) => Horizon::supervisor($name)->processes())
    ->sum();

echoResult('Supervisor processes > 0', $processes > 0);

Run it inside the container:

docker exec -it app php scripts/horizon-check.php

All checks must return ✅. Any ❌ indicates the exact point to fix.

Custom Supervisor Configuration for Latency Handling

Create a supervisor config file that Horizon will read (docker/worker-supervisor.conf):

[program:horizon]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/html/artisan horizon
numprocs=4
autostart=true
autorestart=true
startsecs=5
stopwaitsecs=30   ; increase from default 10 to allow long jobs
stdout_logfile=/var/log/horizon_stdout.log
stderr_logfile=/var/log/horizon_stderr.log
environment=APP_ENV="production",APP_DEBUG="false"

Rebuild and restart the container:

docker compose up -d --build
supervisorctl reread && supervisorctl update

The higher stopwaitsecs prevents Horizon from killing jobs that exceed the default 10‑second grace period.

Alerting Pattern for Failed Jobs

1. Register a listener (app/Listeners/NotifyFailedJob.php)

<?php

namespace App\Listeners;

use Illuminate\Queue\Events\JobFailed;
use Illuminate\Support\Facades\Notification;
use App\Notifications\JobFailedNotification;

class NotifyFailedJob
{
    public function handle(JobFailed $event)
    {
        Notification::route('slack', config('services.slack.webhook'))
            ->notify(new JobFailedNotification($event->connectionName, $event->job->resolveName(), $event->exception->getMessage()));
    }
}

2. Notification (app/Notifications/JobFailedNotification.php)

<?php

namespace App\Notifications;

use Illuminate\Notifications\Notification;
use Illuminate\Notifications\Messages\SlackMessage;

class JobFailedNotification extends Notification
{
    private $connection;
    private $job;
    private $error;

    public function __construct(string $connection, string $job, string $error)
    {
        $this->connection = $connection;
        $this->job = $job;
        $this->error = $error;
    }

    public function via($notifiable)
    {
        return ['slack'];
    }

    public function toSlack($notifiable)
    {
        return (new SlackMessage)
            ->error()
            ->content('Laravel Horizon job failed')
            ->attachment(function ($attachment) {
                $attachment->title('Job Details')
                    ->fields([
                        'Connection' => $this->connection,
                        'Job' => $this->job,
                        'Error' => $this->error,
                    ]);
            });
    }
}

3. Register the listener in EventServiceProvider

protected $listen = [
    \Illuminate\Queue\Events\JobFailed::class => [
        \App\Listeners\NotifyFailedJob::class,
    ],
];

Now every failed job triggers a Slack alert, giving you immediate visibility.

Configuration Comparison Table

Setting

Default (Laravel 11)

Recommended for Production

wait

60 seconds

120 (allows longer back‑off)

sleep

3 seconds

5 (reduces CPU churn)

timeout

60 seconds

Align with job $timeout (e.g., 30)

memory

128 MB

256 MB for heavy payloads

stopwaitsecs (supervisor)

10

30 or higher

Common Pitfalls & Troubleshooting

  • Redis connection refused – Verify Docker network alias (redis) matches config/database.php redis.host. Use docker exec redis redis-cli ping.

  • Horizon shows jobs but failed_jobs table stays empty – Ensure QUEUE_CONNECTION=redis and HORIZON_QUEUE=redis are set; also run php artisan queue:failed-table && php artisan migrate.

  • Supervisor kills jobs before completion – Increase stopwaitsecs and match job $timeout.

  • Missing php artisan horizon process – Check that the supervisor config file is mounted into the container and that supervisorctl status lists horizon processes.

  • Failed jobs not retried – Set $tries on the job class and configure retry_after in config/queue.php.

FAQ

  • Why do failed jobs not appear in Horizon's UI? Because the failed_jobs table is missing or the queue connection is mis‑configured; run the migration and ensure QUEUE_CONNECTION=redis.

  • How can I see the exact exception that caused a failure? Use php artisan horizon:failed or query the failed_jobs table; the exception column contains the stack trace.

  • What Redis setting most often leads to silent job stalls? An exhausted maxmemory policy that evicts keys; increase maxmemory or switch to noeviction in redis.conf.

  • Do I need to restart Horizon after changing config/horizon.php? Yes, run php artisan horizon:terminate which gracefully restarts all workers with the new config.

Related Engineering Guides

Connect with KoderSolution 🚀

Master modern software engineering, scalable architectures, and advanced backend systems.

What's your setup?How does your production stack handle background jobs? Are you using Redis with Horizon, or cloud queues like AWS SQS?

Recommended Resources & Courses

Partner Links
Callum Davies
C
Written by•Senior Backend Engineer

Callum Davies

PHP & Laravel practitioner since v5.4. Big believer in thin controllers, explicit return types, and boring modular architecture that doesn't page you on weekends.

React to this article