I was setting up a new Laravel Forge server for my SaaS app (Growth Method) and noticed a background daemon listed for Laravel Horizon. My first question was simple: do I still need this?
The answer was no.
Table of contents
Open Table of contents
What does Horizon actually do?
Think of your app like a restaurant. When a customer places an order, some things happen immediately — the waiter confirms the order. But other things happen in the kitchen — the chef prepares the food, plates it, and sends it out.
Laravel’s queue system is the kitchen. It handles tasks that your app can’t do instantly during a page load — things like sending emails, calling external APIs, or running AI processing. These tasks get placed in a queue and a background worker picks them up one by one.
Laravel Horizon is the kitchen manager. It’s a layer on top of the queue system that adds a real-time dashboard, auto-scaling of worker processes, code-driven configuration, and alerting when queues back up. It’s built and maintained by the Laravel team.
But it’s not always the right choice.
The three options
There are three main approaches for processing background jobs in Laravel.
Option 1: Laravel Horizon
Horizon wraps your queue workers with centralised management. You configure everything in config/horizon.php (version-controlled), and it handles spawning workers, auto-scaling based on load, and monitoring via a Vue.js dashboard at /horizon.
The catch: Horizon only works with Redis. If you use database, SQS, or Beanstalkd queues, Horizon isn’t an option.
Option 2: Forge Queue Worker
Laravel Forge has built-in queue worker management. You click the + button on the Queue tab to add a new worker, configure the connection, queue name, tries, and timeout through the UI, and Forge handles the Supervisor configuration behind the scenes. It keeps the worker running, restarts it on deploys, and monitors the process.
This works with any queue driver — Redis, database, SQS, whatever you’re using.
Option 3: Sync Driver
Set QUEUE_CONNECTION=sync and all background tasks execute immediately during the HTTP request. No background worker needed at all.
This is fine for local development and testing, but not for production. If your background tasks include AI API calls with 120-second timeouts, your users would stare at loading spinners for 10-30 seconds after every action.
Comparison table
| Horizon | Forge Queue Worker | Sync Driver | |
|---|---|---|---|
| Complexity | Medium | Low | None |
| Dashboard | Yes | No | N/A |
| Auto-scaling | Yes | No (fixed processes) | N/A |
| Driver support | Redis only | Any | N/A |
| Configuration | Code (config/horizon.php) | Forge UI | N/A |
| Maintenance | Extra package | Built into Forge | Nothing |
| Right for a small app? | Overkill | Yes | Too slow |
What my app actually needed
I audited my codebase. Here’s what I found:
- 3 queued jobs — AI-powered design generation, idea summarisation, and company analysis
- 9 queueable event listeners — AI scoring, categorisation, summaries, and email notifications
- 1 queue called
default— no routing complexity - No scheduled queue tasks — all work is event-triggered
- 1 developer (me) — no team needing a monitoring dashboard
Horizon is designed for apps with significant queue complexity: multiple queues, variable workloads requiring auto-scaling, teams of developers who need a monitoring dashboard, high job volumes where visibility matters. My app had none of that.
It was like hiring a full-time kitchen manager for a cafe that serves 20 meals a day.
What the Laravel community says
The community consensus is straightforward: use Horizon when you need its features, use plain workers when you don’t.
Taylor Otwell built Horizon because he needed deep insight into queue throughput, performance, and failures. But he also published “Forge Queue Workers Explained” (via Mohamed Said) — a guide on using Forge’s built-in Supervisor-managed workers. The existence of both approaches acknowledges that many apps run perfectly well without Horizon.
Mohamed Said, who co-created Horizon alongside Taylor, has written extensively about how queue:work functions internally. His articles on queue worker mechanics, memory management, and CPU rationing focus on the fundamentals of queue:work itself — without positioning Horizon as always necessary.
Freek Van der Herten (Spatie) uses Horizon in production and has written about setting it up with Forge and Envoyer. He’s also built packages around it like laravel-horizon-watcher. But Spatie runs complex applications with significant queue workloads — their usage validates Horizon for teams that need monitoring at scale, not for every app.
Jason McCreary (Laravel Shift) built custom auto-scaling on top of Horizon that reduced his weekly automation runtime from 4 hours to 32 minutes and cut server costs by 97%. His use case — spawning worker servers based on queue load — is where Horizon earns its keep: high-volume, variable-demand workloads.
What I changed
The code changes were minimal. I removed four things:
laravel/horizonfromcomposer.jsonconfig/horizon.php— Horizon’s configuration fileHorizonServiceProvider— the service provider and its registration inbootstrap/providers.phphorizon:snapshot— the scheduled command that powered Horizon’s metrics dashboard
Then on Forge, I added a queue worker with these settings:
- Connection:
redis - Queue:
default - Processes:
1 - Tries:
3 - Timeout:
60 - Memory:
128
That’s it. Same background job processing, fewer moving parts.
The retry_after gotcha
While making this change, I caught a configuration bug that had been hiding in my app the whole time.
My three AI jobs each define public $timeout = 120 — they’re allowed to run for up to 2 minutes (AI API calls can be slow). But in config/queue.php, the retry_after setting was 90 seconds:
'redis' => [
'driver' => 'redis',
'retry_after' => 90, // Problem: shorter than job timeout
],
The retry_after value tells the queue system how long to wait before assuming a job is stuck and re-dispatching it. If a job can run for 120 seconds but retry_after is only 90, the queue will re-dispatch the job while it’s still running — causing duplicate processing.
The fix was simple — increase retry_after to 180 seconds (3 minutes), giving the longest-running jobs plenty of breathing room:
'redis' => [
'driver' => 'redis',
'retry_after' => 180, // Safely longer than any job timeout
],
This is worth checking in your own app. Your retry_after value should always be longer than your longest job timeout, otherwise you’ll get duplicate job execution with no obvious error message to alert you.
What would Taylor Otwell do?
Taylor would ask one question: what’s the simplest thing that works?
He built Horizon because he needed it for apps with complex queue requirements. He also built Forge’s queue worker UI because he knows most apps don’t. Laravel’s philosophy is about having powerful tools available when you need them, not about using every tool in every project.
Looking at my app — 3 jobs, 1 queue, 1 developer, light workload — Taylor would remove Horizon, add a Forge queue worker, and move on to building features. Spend zero more time thinking about queue infrastructure.
The Laravel way is: use the simplest tool that solves your problem, and reach for more powerful tools when you outgrow the simple ones. I haven’t outgrown a Forge queue worker. If I do — multiple queues, high job volumes, a team that needs a monitoring dashboard — I can always add Horizon back. That’s the beauty of YAGNI.