All Articles

My Laravel Horizon preferences after 5 years of using it

· Written by Vincent Bean

When working with Laravel Horizon, it’s easy to get started, run php artisan horizon, dispatch some jobs, monitor them in the dashboard, and you’re good to go. But as your application scales and the amount of jobs grow more complex, subtle issues begin to surface. The design of your jobs and configuration of Horizon and Redis must be correct in order to run Horizon without issues.

I've been working with Laravel Horizon for the past five years on Laravel applications that do 1M+ jobs per day. In this article, I’ll share what I’ve learned from working with Laravel Horizon for five years. Including how I prefer to design jobs; how I isolate queues to prevent bottlenecks; and most importantly, the hidden pitfalls of Horizon's configuration and how to avoid them. If you’ve ever had a job mysteriously fail to dispatch or vanish without explanation, read this article.

I've added a few code examples from my open source project, Vigilant. An all-in-one website monitoring application.

Designing Jobs: Slim, small and quick

I prefer to keep my jobs slim, the job class should be responsible for how it runs on the queue and not running the actual logic of the action. I usually decouple the logic in seperate action class. Decoupling the logic from your job provides a few advantages, first of all your logic is not dependent on a job and can be called from anywhere. This is good practice as it makes your code more flexible. For example, if you later decide that you need the same code in a controller it is better to call a separate class with your logic than dispatching your job synchronously. Dispatching a job synchronously defeats the purpose of putting your code in a job anyway.

Let's take a look at one of Vigilant's jobs, the CheckUptimeJob. As you can guess this job is responsible for checking if a service is up.

<?php

namespace Vigilant\Uptime\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Vigilant\Core\Services\TeamService;
use Vigilant\Uptime\Actions\CheckUptime;
use Vigilant\Uptime\Models\Monitor;

class CheckUptimeJob implements ShouldBeUnique, ShouldQueue
{
    use Dispatchable;
    use InteractsWithQueue;
    use Queueable;
    use SerializesModels;

    public function __construct(public Monitor $monitor)
    {
        $this->onQueue(config('uptime.queue'));
    }

    public function handle(CheckUptime $uptime, TeamService $teamService): void
    {
        $teamService->setTeamById($this->monitor->team_id);
        $uptime->check($this->monitor);
    }

    public function uniqueId(): int
    {
        return $this->monitor->id;
    }
}

As you can see, the handle method only sets the environment and calls the action. The constructor just accepts the properties it needs to run and it defines an unique id. All the jobs in Vigilant are written like this, they are just a way of running logic on the queue.

I have also found that more jobs that have little runtime are better than a single job that does a lot and takes a while to complete. For example, when there are 100 sites that Vigilant needs to check the uptime for it will dispatch 100 jobs instead of one job that checks all the sites. This has a few benefits, first of all you can utilize Horizon's multiple processes per queue running the jobs in parallel.

But the greatest benefit of running small jobs is error handling. When a job fails you know exactly with what arguments the job was ran (if you set the right tags!). That way it will be easier to find the cause of the failure. You can even implement the failed method and add extra logging to your Eloquent model for example. That way you'll always know when something went wrong and what the exception was.

Queue configuration: Isolate workloads

Deciding on what queues to have, how many processes to allocate and how to configure them is heavily dependent on your application. If you do not have many different jobs a single queue is fine. As you scale you'll notice that you'll need to split them up so that unrelated processes don't block each other.

For Vigilant I've chosen to separate the queues by the type of monitor. This way a web crawler cannot block an uptime monitor. These two types of monitor also have very different requirements. Uptime monitoring is strict and must run at a specified interval. That means that there always should be a worker available to pick up a job.
When crawling a website it is not a problem if a job is waiting in the queue for a few minutes, as long as it gets run.

Unique Jobs: Misconfiguration will cause them to never run

Unique jobs are great for ensuring that a job is never dispatched twice, but when misconfigured it can cause strange issues such as your job not dispatching. Most jobs in Vigilant are unique, for example, the uptime monitor dispatches a job at a specified interval but if for any reason the job hasn't executed within that interval I do not want another job on the queue.

Unique jobs work by storing an unique lock key in your cache, as long as that key exist a new job will not be dispatched. Normally the key gets created when the job is dispatched and removed when a job finishes. It does not matter if your job completes successfully or fails, the lock will always be removed.

I've written a whole article about this in the past, you can check it out here.

Job timeouts

When you've worked with Horizon you'll probably recognize the job timeout exceeded exception. I just want to highlight two configuration entires that you should configure based on your slowest job.
First of all the timeout setting in your Horizon queue worker which can be found in config/horizon.php.

Secondly Laravel's config/queue.php contains the retry_after setting which you should configure at the same value.

Redis configuration for Horizon

As Horizon is built on Redis you cannot skip this configuration. There are two settings that are important:

  1. maxmemory

  2. maxmemory-policy

The maxmemory option should be obvious, Redis should have enough memory to store your jobs. But the more important option of the two is the policy. What should Redis do if the memory is full? These are the options:

# volatile-lru -> Evict using approximated LRU, only keys with an expire set.
# allkeys-lru -> Evict any key using approximated LRU.
# volatile-lfu -> Evict using approximated LFU, only keys with an expire set.
# allkeys-lfu -> Evict any key using approximated LFU.
# volatile-random -> Remove a random key having an expire set.
# allkeys-random -> Remove a random key, any key.
# volatile-ttl -> Remove the key with the nearest expire time (minor TTL)
# noeviction -> Don't evict anything, just return an error on write operations.

When working with jobs we never want to remove any. All jobs that are pushed onto the queue should be picked up by Horizon. With this knowledge the only way we can guarantee that all jobs will be picked up is by setting the policy to noeviction. This means that the part of your application that dispatches the job will get an exception when Redis is full. This is preferable as the alternative is that a random job will be removed and with an exception you get alerted when Redis is full.

While the no eviction policy is the best for queues, it's not the best for cache. This is why I always run two Redis instances, one for the queue and one for cache.

Monitoring Horizon beyond the basics

Horizon's default dashboarding is lacking, it shows the basic information but not enough when you start running a large amount of jobs. It is possible to expand the monitoring of Horizon because Horizon and Laravel's queueing system provide a few events that you can hook into. You can also query the Redis database to gain specific insights.

In the past I've written an alternative dashboard for Horizon which I've since have abandoned in favour of Prometheus / Grafana. I've found that collecting metrics and publishing them to Prometheus is far superior than storing them in a MySQL database. I do not have any open source code for this way monitoring but here are a few ideas which are possible:
Export all pending, completed and failed jobs over time so that you get a historic overview of what runs when and how long large processes take.
Keep track of job runtimes to get an idea which jobs are the slowest and can be optimized.
Count the unique jobs and unique locks to check for differences.

As Horizon is built on Redis it is also important to monitor Redis, especially the memory and maxmemory usage.

Final Thoughts

After five years of working with Laravel Horizon, my biggest takeaway is this: Horizon is incredibly powerful—but only if you treat it with care and intention.

Many of the issues developers face with Horizon aren’t bugs, it is usually incorrect configuration.
Whether it's isolating queues to prevent bottlenecks, understanding how unique job locks work under the hood, or fine-tuning Redis configuraiton to avoid deleted jobs.

The strategies I’ve shared, from designing slim, single-responsibility jobs to building out advanced monitoring with Prometheus have helped me build reliable, observable, and maintainable queue systems. If you're just starting with Horizon or you're troubleshooting mysterious job behavior, I hope this post helps you avoid some of the sharp edges I’ve encountered along the way.

Thanks for reading, feel free to dig into Vigilant if you’re curious how these patterns look in a real world app.

If you found this helpful, please consider starring the repository 🙏

Start Monitoring within minutes.

Get started with Vigilant in a few minutes, sign up, enter your website and select your monitors.
Vigilant comes with sensible defaults and requires minimal configuration.

Start Monitoring