Do You Need Laravel Horizon? Plain Workers vs. Horizon in Production | Deploynix Laravel Blog
Back to Blog

Do You Need Laravel Horizon? Plain Workers vs. Horizon in Production

Sameh Elhawary · · 19 min read
Do You Need Laravel Horizon? Plain Workers vs. Horizon in Production

There are two kinds of Laravel teams, and both are running on autopilot. The first kind runs composer require laravel/horizon on every new project, before a single job class exists, because that's just what you do. The second kind has been running php artisan queue:work under Supervisor for years and has never once stopped to ask whether they're missing anything. Both are unexamined defaults. Both camps are sometimes wrong.

Horizon is not "queues, but better". It's a specific tool that trades a hard Redis-protocol requirement and some real operational overhead for two things: visibility into your queue system, and load-based balancing of your worker pool. Whether that trade pays off depends on your queue driver, your queue topology, and, honestly, whether anyone on your team will ever open the dashboard after week two.

We've configured queue workers for a lot of Laravel apps, from single-server side projects on the database driver to dedicated worker fleets chewing through webhook storms. The pattern we keep seeing is that the Horizon decision is rarely made on the merits. So in this guide we'll lay out what plain workers already give you (it's more than most people think), what Horizon concretely adds and what each addition costs, and a decision framework that ends with an actual verdict for your situation. Not "it depends". A verdict.

What Do Plain Queue Workers Already Give You?

Here's the part the Horizon-by-default crowd tends to forget: queue:work is not the budget option. It's the same worker loop Horizon itself runs under the hood. Almost everything people credit to Horizon, retries, backoff, timeouts, failure handling, is core Laravel queue behavior that you get with zero extra packages.

A plain worker on any driver gives you automatic retries with --tries, exponential or fixed backoff via --backoff or a backoff() method on the job, per-job timeouts, and a failed_jobs table that captures the full exception and payload when a job exhausts its attempts. You can retry failed jobs from the CLI with queue:retry, inspect them with queue:failed, and prune them on a schedule.

Priority handling works too, and it's simpler than people expect. Pass a comma-separated queue list and the worker drains them strictly in order:

[program:app-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/artisan queue:work redis --queue=payments,default,emails --sleep=3 --tries=3 --backoff=10 --max-time=3600
autostart=true
autorestart=true
user=deploynix
numprocs=4
redirect_stderr=true
stdout_logfile=/var/www/app/storage/logs/worker.log
stopwaitsecs=120

That stanza gives you four workers that always check payments first, then default, then emails. Want more throughput on one queue? Add a second program block with numprocs=8 pinned to that queue alone. Per-worker tuning of memory limits, sleep intervals, and PHP versions is all right there in the command line. And critically, this works on every queue driver Laravel supports: database, Redis, Valkey, SQS, Beanstalkd. We covered the driver and retry mechanics in depth in our Laravel queues deep dive, and none of it requires Horizon.

What Plain Workers Don't Give You

So what's actually missing? Four things, and they're all about observation and adaptation rather than execution.

First, visibility. With plain workers, your window into the queue is redis-cli llen, database queries, or log grepping. There's no answer to "what's our p95 wait time on the exports queue" without building it yourself. Second, load-based balancing. Your numprocs allocation is static. If emails is empty and webhooks is drowning, those email workers sit idle. Third, tag-based search. Finding every job related to customer #4182 across pending, processing, and failed states is a manual archaeology project. Fourth, one-click retry from a UI, which matters more than it sounds when a non-CLI teammate is on support duty.

Strengths: Runs on any queue driver. Dead simple mental model: Supervisor keeps N processes alive, each process runs jobs. No extra package to update, no dashboard to secure, no extra memory overhead. Static allocation is predictable capacity planning.

Best for: Single-queue apps, database-driver apps, teams that alert on symptoms (queue depth, oldest-job age) rather than watching dashboards, and anyone who values fewer moving parts over richer tooling.

Considerations: Zero built-in observability. Worker allocation can't adapt to shifting load between queues. Operational queue questions get answered with ad-hoc CLI spelunking, usually during an incident, which is the worst time to be writing Redis commands from memory.

What Does Horizon Actually Add?

Horizon is a supervisor for your workers and a dashboard for your queues, in one package. Understanding the first half is the key to understanding its ops model: you don't run one Supervisor process per worker anymore. You run exactly one horizon process, and Horizon forks and manages its own pool of child workers according to your config. Supervisor watches Horizon; Horizon watches everything else.

That inversion is what makes the features possible. Because Horizon owns the pool, it can resize it. Let's go feature by feature, with the cost attached to each.

The dashboard. Real-time throughput, per-queue wait times, job runtime distributions, failed job details with full stack traces and payloads. Cost: it's a route in your app that must be auth-gated in production (more on that below), and the metrics have retention limits, so it complements rather than replaces real monitoring.

Auto-balancing. This is the killer feature, and we'll say that plainly. With balance => 'auto', Horizon shifts worker processes between queues based on current load and wait time. When webhooks spikes, workers migrate there; when it drains, they flow back. Cost: essentially none beyond running Horizon at all. This feature alone is the reason most teams who should adopt Horizon should adopt it.

Job tags. Jobs are auto-tagged with their Eloquent model IDs, and you can add custom tags. Searching "everything touching order 5512" becomes a text box. Cost: tag data lives in Redis, adding a bit of memory usage on your Redis or Valkey instance.

One-click retry and failed job UX. A support engineer can find and retry a failed job without SSH access. If you've ever lost an afternoon to debugging queue failures in production, you know what this is worth.

Metrics snapshots. Throughput and runtime trends over time, powered by a scheduled horizon:snapshot command. Cost: you must remember to schedule it, and plenty of teams don't, then wonder why the metrics tab is empty.

Now the bill for all of it. Horizon requires the redis queue driver, full stop. It works perfectly with Valkey because Valkey speaks the Redis wire protocol, but database, SQS, and Beanstalkd queues are out. Each Horizon-managed worker carries a bit more memory overhead than a bare queue:work process, which adds up on small servers running large pools. And you've added a package that must track your Laravel version on every upgrade.

Strengths: Load-adaptive worker allocation, genuine observability, tag search, humane failed-job workflow, and centralized worker config in a PHP file that ships with your code instead of living in server-side ini files.

Best for: Multi-queue apps on Redis or Valkey with uneven, shifting load, teams where multiple people interact with queue operations, and anyone running enough job volume that "what is the queue doing right now" is a daily question.

Considerations: Redis-protocol stores only. Extra memory per process. A dashboard that's a security hole if left ungated. One more dependency in every framework upgrade. And a subtle one: Horizon becomes a single point of failure for your whole worker pool, so you must monitor the monitor.

Plain Workers vs. Horizon: Side by Side

Before the decision framework, here's the full comparison in one place. We've split it into what the software does and what running it demands of you, because the second table is where most blog posts go quiet.

CapabilityPlain queue:workHorizon
Queue driversDatabase, Redis/Valkey, SQS, BeanstalkdRedis/Valkey only
Retries, backoff, timeoutsYes (core Laravel)Yes (same core mechanics)
Failed job storagefailed_jobs tablefailed_jobs table + dashboard UI
Queue priorityStatic, via --queue=a,b,c orderPriority order plus load-based balancing
Worker allocationFixed numprocs per stanzaDynamic (auto), even (simple), or fixed (false)
VisibilityCLI + logs, build your ownDashboard: wait times, throughput, runtimes
Job search by tagNoYes
Retry from UINoYes
Long-term metricsNoSnapshots (with scheduled command)
Operational concernPlain queue:workHorizon
Supervisor programs to manageOne per worker groupExactly one
Worker config lives inServer-side ini filesconfig/horizon.php, versioned with code
Deploy-time restartphp artisan queue:restartphp artisan horizon:terminate
Extra scheduled tasksOptionally queue:prune-failedhorizon:snapshot every 5 min + pruning
Dashboard security workNone (no dashboard)Required: gate or Horizon::auth
Memory footprintBaselineBaseline + master process + per-child overhead
Failure blast radiusOne dead process = one worker downHorizon dies = entire pool down
Package upgrade surfaceNoneTracks Laravel major versions

Read that second table honestly. Horizon centralizes and simplifies a lot, one process, config in git, but it concentrates risk and adds obligations. Neither column is free.

Which Three Questions Decide It?

Skip the twenty-point checklists. In practice, three questions settle this decision for almost every team we've worked with. Answer them in order.

Question 1: Are You Already on Redis or Valkey?

This is a gate, not a preference. Horizon requires a Redis-protocol queue store. If you're on the database driver and it's meeting your throughput needs, Horizon would force a driver migration first, and "install a dashboard" is a bad reason to change your queue's storage engine. If you're on SQS because you like managed infrastructure, same answer: Horizon is simply off the table.

If you're already on Redis, or on Valkey, which is wire-compatible and works with Horizon without any configuration difference, the gate is open and you move to question two. Unsure about the Valkey side of that sentence? We compared them directly in Valkey vs. Redis for Laravel caching and queues; the short version is that for queue workloads they behave identically.

Question 2: Do You Have Multiple Queues With Shifting Load?

One queue with steady volume gains almost nothing from auto-balancing, because there's nothing to balance. But the moment you're running mail, exports, webhooks, and default, and their relative load changes hour to hour, static numprocs allocation forces an ugly choice: overprovision every queue for its worst hour, or accept latency when the spike lands on the underweighted one.

Horizon's auto strategy dissolves that trade-off by moving workers to wherever the wait time is. In our experience this is the single feature that justifies Horizon on its own. If you answered yes here and yes to question one, you should probably be running Horizon, and the rest is implementation detail.

Question 3: Will Anyone Actually Look at the Dashboard?

Be honest. A dashboard nobody opens is negative value: it's attack surface, memory, and upgrade work in exchange for nothing. If your team's operational style is alert-driven, page me when queue depth exceeds X or the oldest job is older than Y, and nobody will browse queue graphs between incidents, then Horizon's observability features will rot quietly.

But if support engineers need to look up a customer's stuck export, or you review throughput trends when planning capacity, the dashboard earns its keep weekly. Two or three yes answers means install it. One yes, on question two, still usually means install it, because auto-balancing works whether or not anyone watches. Zero yes answers means plain workers, and you should feel good about that.

Which Setup Fits Your Team Profile?

The three questions generalize, but most teams recognize themselves in one of a few profiles. Here's how we'd call each one.

ProfileDriverQueuesVerdict
Solo dev, small appDatabaseOne default queuePlain workers, full stop
Small team, modest volumeRedis/Valkey1-2 stable queuesPlain workers, revisit at 3+ queues
SaaS with mixed workloadsRedis/ValkeyMail, exports, webhooks, spikyHorizon, it earns its keep
High-scale worker fleetRedis/ValkeyMany queues, multiple serversHorizon per worker server

The solo developer with a single default queue on the database driver should not install Horizon, and we'd go further: doing so is a small mistake. You'd take on a Redis migration, a security obligation, and an upgrade dependency to get a dashboard for a queue you can fully understand with php artisan queue:failed and a depth check. Spend that effort on your product.

The SaaS profile is Horizon's home turf. Mail must go out promptly, exports are heavy and bursty, webhooks arrive in storms you don't control. This is exactly the shifting multi-queue load that auto-balancing was built for, and the tag search pays for itself the first time support asks "did customer X's report ever generate?"

The high-scale fleet, dedicated worker servers processing serious volume, runs one Horizon process per worker server, all pointed at the same Redis or Valkey backend. Each Horizon instance balances its local pool; the dashboard aggregates across all of them. Capacity beyond that is a horizontal scaling story, which we walked through in processing 1 million jobs a day. The one caution at this scale: Horizon's metrics are queue-level, not host-level, so you still need real server monitoring underneath it.

How Do You Run Each Well in Production?

Whichever side you land on, the difference between a solid setup and a flaky one comes down to a handful of details. Here they are for both paths.

Supervising Horizon Correctly

The most common Horizon mistake we see is treating it like a plain worker in Supervisor. It isn't. You run exactly one process, and because Horizon forks children, the stop settings matter enormously:

[program:horizon]
process_name=%(program_name)s
command=php /var/www/app/artisan horizon
autostart=true
autorestart=true
user=deploynix
redirect_stderr=true
stdout_logfile=/var/www/app/storage/logs/horizon.log
stopwaitsecs=3600
stopasgroup=true
killasgroup=true

Three lines carry the weight here. stopwaitsecs must exceed your longest-running job, or Supervisor will SIGKILL Horizon mid-job during a restart and you'll get mysterious half-completed work. If your biggest export takes 40 minutes, 3600 seconds is right; a default of 10 is a data-corruption machine. stopasgroup and killasgroup ensure signals reach the child workers Horizon spawned, not just the master, so you never orphan a pool of zombie workers processing jobs nobody is supervising.

Then the pool itself lives in config/horizon.php, versioned with your code:

'environments' => [
    'production' => [
        'supervisor-1' => [
            'connection' => 'redis',
            'queue' => ['mail', 'webhooks', 'default'],
            'balance' => 'auto',
            'minProcesses' => 2,
            'maxProcesses' => 12,
            'balanceMaxShift' => 2,
            'balanceCooldown' => 3,
            'tries' => 3,
            'timeout' => 90,
        ],
        'supervisor-exports' => [
            'connection' => 'redis',
            'queue' => ['exports'],
            'balance' => false,
            'processes' => 3,
            'tries' => 1,
            'timeout' => 2400,
        ],
    ],
],

Note the three balance strategies in action. auto shifts processes between mail, webhooks, and default based on load. simple (not shown) would split them evenly and never move them. false gives the exports queue its own dedicated fixed-size supervisor, which is the right call for long-running jobs whose timeout profile differs wildly from everything else. Mixing strategies across supervisors like this is normal and encouraged; the official Horizon docs cover the full option set.

Deploys, Dashboard Security, and Snapshots

Deploys must tell workers to finish their current job and restart on the new code. For plain workers that's php artisan queue:restart; Horizon's parallel is horizon:terminate, and Supervisor's autorestart brings it back up on the fresh release:

# deploy hook, after the new release is linked
php artisan config:cache
php artisan migrate --force
php artisan horizon:terminate   # graceful: finishes in-flight jobs, then exits
# plain-worker equivalent: php artisan queue:restart

Two more obligations that plain workers don't have. First, gate the dashboard. Out of the box Horizon is only accessible in the local environment; in production you define who gets in via the viewHorizon gate in HorizonServiceProvider (or Horizon::auth). Ship it ungated and you've published your job payloads, which routinely contain emails, IDs, and tokens, to the internet. Second, schedule horizon:snapshot every five minutes, or your metrics tab stays empty forever:

Schedule::command('horizon:snapshot')->everyFiveMinutes();

Who Watches the Watcher?

Here's the failure mode nobody plans for: Horizon itself dies. Because it supervises your entire pool, its death takes every worker with it, and the dashboard that would have told you can't load because the process behind it is gone. Supervisor's autorestart handles crashes, but not a wedged process, a full disk, or an OOM-killed master on a memory-starved box.

So monitor the monitor. Alert on queue depth and oldest-job age from outside the Horizon process, and watch host-level CPU and memory on your worker servers so you catch the OOM spiral before it kills the pool. We've found that queue-level metrics and host-level server monitoring fail at different times, which is exactly why you want both.

Running Both Paths on Deploynix

Since we build deployment infrastructure for Laravel, here's concretely how the two paths look on our platform. This maps one-to-one onto everything above, so it doubles as a worked example even if you're provisioning by hand.

The plain-worker path is the queue workers UI. You define a worker per app: connection, queue list, process count, timeout, tries, sleep, and PHP version, and Deploynix generates and installs the corresponding Supervisor config on the server. It's the exact stanza pattern from earlier in this post, just without hand-editing ini files over SSH. Change the process count in the UI, and Supervisor reloads with the new allocation.

The Horizon path deliberately does not use that feature. Because Horizon supervises its own pool, you run it as a daemon instead: one managed long-running php artisan horizon process with the group-signal and stop-timeout semantics handled for you. Your pool sizing then lives where it should, in config/horizon.php in your repository. The driver requirement is already met, since Deploynix app servers ship with Valkey, which Horizon treats identically to Redis. For dedicated fleets, the worker server type gives you hosts whose only job is running Horizon against your queue backend, and zero-downtime deploys can run horizon:terminate in a deploy hook so releases never kill in-flight jobs.

One thing we'd flag from experience: teams sometimes assume Horizon's dashboard means they can skip server monitoring on worker boxes. It can't. Horizon sees queues; it doesn't see the memory pressure that's about to OOM-kill it. Our server monitoring watches CPU, memory, and load on the host, which is precisely the layer that catches Horizon's own failure modes. If you go the Horizon route, we've written up the full production setup in running Laravel Horizon on Deploynix.

How Hard Is It to Migrate Between the Two?

Good news: this decision is cheap to reverse, in both directions. Your job classes, retry logic, backoff methods, and failed_jobs table are identical under both systems, because Horizon reuses Laravel's core queue mechanics rather than replacing them. Migration is an infrastructure swap, not a rewrite.

Going from plain workers to Horizon: get on Redis or Valkey if you aren't already (the only potentially non-trivial step), install the package, translate each Supervisor stanza into a Horizon supervisor block with matching queue lists and process counts, then replace your N worker programs with the single Horizon program. Swap queue:restart for horizon:terminate in your deploy script, gate the dashboard, schedule snapshots. Start with balance => 'simple' to reproduce your old static allocation exactly, confirm throughput matches, then switch to auto and watch the wait times drop.

Going the other way is even simpler, and yes, teams do it, usually after realizing nobody opened the dashboard in six months. Read your process counts out of config/horizon.php, write the equivalent queue:work stanzas, stop Horizon, start the workers, swap the deploy command back. You lose tags, balancing, and the UI; you keep every job, every retry policy, and every failed-job record. Either migration is an afternoon, not a quarter. That's worth internalizing, because it means you should decide for the team you are now, not the team you might be in three years.

FAQ

Does Horizon work with Valkey?

Yes, without any special configuration. Valkey speaks the Redis wire protocol, so Laravel's Redis queue driver, and therefore Horizon, can't tell the difference. Point your redis connection at a Valkey instance and Horizon's balancing, tags, and metrics all work exactly as they do on Redis.

Can I use Horizon with the database or SQS queue driver?

No. Horizon requires the Redis queue driver; that's a hard dependency, not a soft recommendation. If you're on database or SQS and happy, stay on plain queue:work workers, which support every driver. Adopting Horizon means migrating your queue store first, and that migration should be justified on its own merits.

Is Horizon faster than plain queue:work?

Not per job. Individual jobs execute through the same worker mechanics either way, and Horizon's children actually carry slightly more memory overhead. Where Horizon improves real-world latency is allocation: auto-balancing moves workers to backed-up queues, so jobs wait less even though they don't run faster.

Do I still need Supervisor if I use Horizon?

Yes, just less of it. Something must keep the horizon master process alive across crashes and reboots, and that's Supervisor (or systemd, or a platform-managed daemon). The difference is you supervise one process instead of one per worker, and Horizon manages the pool beneath it.

What happens to my failed jobs if I switch to or from Horizon?

Nothing. Both setups write failures to the same failed_jobs table with the same payloads and exceptions, and queue:retry works identically in both worlds. Horizon adds a UI over that table; removing Horizon removes the UI, not the data.

The Verdict

Here's the framework compressed to its core. On the database driver or SQS with no pain? Plain workers, and don't let anyone make you feel behind for it. On Redis or Valkey with multiple queues and shifting load? Horizon, primarily for auto-balancing, with the dashboard as a bonus. Somewhere in between? Default to plain workers and let a real problem, not a habit, pull you toward Horizon. The migration is cheap, so decide for today.

Whichever way you land, the production details decide whether it works: correct stop timeouts, graceful restarts on deploy, a gated dashboard, and host-level monitoring underneath it all. Your next step is a fifteen-minute audit of your current setup against the stanzas in this post, starting with stopwaitsecs versus your longest job. In our experience, that one line is where most queue setups are quietly broken.

Ready to deploy your Laravel app?

Deploynix handles server provisioning, zero-downtime deployments, SSL, and monitoring — so you can focus on building.

Get Started Free No credit card required

Related Posts