{"id":459,"date":"2026-07-14T13:02:10","date_gmt":"2026-07-14T13:02:10","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/laravel-horizon-in-production-the-queue-setup-i-stopped-babysitting\/"},"modified":"2026-07-14T13:02:10","modified_gmt":"2026-07-14T13:02:10","slug":"laravel-horizon-in-production-the-queue-setup-i-stopped-babysitting","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/laravel-horizon-in-production-the-queue-setup-i-stopped-babysitting\/","title":{"rendered":"Laravel Horizon in Production: The Queue Setup I Stopped Babysitting"},"content":{"rendered":"<p>Confession: for about two years I ran Laravel queues the boring way. <code>php artisan queue:work<\/code> behind supervisord, one config per environment, and a Slack alert when jobs went sideways. It mostly worked. Until it didn&rsquo;t.<\/p>\n<p>Last winter I got a 3am ping from a client&rsquo;s app. Their nightly export job had been silently retrying for six hours because a Redis eviction had killed the reserved key mid-run. I logged in, tailed a log, guessed at the state, restarted workers, and went back to bed feeling stupid. That was the week I finally moved every Laravel project I own onto <a href=\"https:\/\/laravel.com\/docs\/12.x\/horizon\" rel=\"nofollow noopener\" target=\"_blank\">Horizon<\/a>.<\/p>\n<p>This is what actually changed. Not the marketing tour. The stuff that mattered on a Tuesday when a queue was misbehaving.<\/p>\n<h2 id=\"what-queuework-actually-leaves-you-doing-yourself\">What queue:work actually leaves you doing yourself<\/h2>\n<p>I don&rsquo;t want to trash <code>queue:work<\/code>. It&rsquo;s fine. But every operational thing I used to bolt on the side, Horizon just does.<\/p>\n<p>A dashboard that shows me current throughput, wait time, and per-queue backlog without SSH. A first-class concept of supervisor processes that scale worker count based on load. Job payload inspection for failed jobs, including tags I set on the job itself. Metrics I can graph without wiring up Prometheus by hand.<\/p>\n<p>The old me had a Grafana panel reading <code>LLEN queues:default<\/code> from Redis, plus a scrappy Python script that parsed Laravel&rsquo;s <code>failed_jobs<\/code> table into Slack notifications. All of that is either gone or three lines of config now.<\/p>\n<p>Here&rsquo;s the shape of my old worker unit for context:<\/p>\n<pre><code class=\"language-ini\">[program:app-worker]\nprocess_name=%(program_name)s_%(process_num)02d\ncommand=php \/var\/www\/app\/artisan queue:work redis --queue=high,default --sleep=1 --tries=3 --max-time=3600\nautostart=true\nautorestart=true\nnumprocs=8\n<\/code><\/pre>\n<p>Eight workers, hardcoded. During a big export I was over-provisioned. At 3am I was under-provisioned. Nothing shifted based on the queue.<\/p>\n<h2 id=\"the-horizon-config-that-actually-matters\">The Horizon config that actually matters<\/h2>\n<p>Install is the usual <code>composer require laravel\/horizon<\/code> then <code>php artisan horizon:install<\/code>. The interesting part is <code>config\/horizon.php<\/code>. This is the shape I run in production:<\/p>\n<pre><code class=\"language-php\">'environments' =&gt; [\n    'production' =&gt; [\n        'supervisor-fast' =&gt; [\n            'connection' =&gt; 'redis',\n            'queue' =&gt; ['emails', 'webhooks'],\n            'balance' =&gt; 'auto',\n            'autoScalingStrategy' =&gt; 'time',\n            'minProcesses' =&gt; 2,\n            'maxProcesses' =&gt; 12,\n            'memory' =&gt; 192,\n            'tries' =&gt; 3,\n            'timeout' =&gt; 60,\n        ],\n        'supervisor-exports' =&gt; [\n            'connection' =&gt; 'redis',\n            'queue' =&gt; ['exports'],\n            'balance' =&gt; 'simple',\n            'minProcesses' =&gt; 1,\n            'maxProcesses' =&gt; 3,\n            'memory' =&gt; 512,\n            'tries' =&gt; 1,\n            'timeout' =&gt; 1800,\n        ],\n    ],\n],\n<\/code><\/pre>\n<p>Two things I got wrong the first time.<\/p>\n<p><code>balance<\/code> matters more than the docs make it sound. <code>auto<\/code> with <code>autoScalingStrategy: time<\/code> is what I actually want in most apps: workers migrate from a quiet queue to a busy one, and the strategy picks based on estimated time to clear the backlog rather than raw job count. <code>simple<\/code> splits workers evenly, which I want only for slow queues where I don&rsquo;t want small jobs starving a big one.<\/p>\n<p>One supervisor per timeout class, not one per queue. I used to define a supervisor per queue. Bad. If your emails need to finish in 60 seconds and your exports need 30 minutes, you want two supervisors. Timeouts are set per-supervisor, not per-job.<\/p>\n<h2 id=\"tag-your-jobs-this-is-the-sleeper-feature\">Tag your jobs (this is the sleeper feature)<\/h2>\n<p>The dashboard&rsquo;s Failed Jobs tab is fine, but the tags are what changed my life. Every job I ship now gets tagged with the entity it touches:<\/p>\n<pre><code class=\"language-php\">class SendInvoiceEmail implements ShouldQueue\n{\n    public function __construct(public Invoice $invoice) {}\n\n    public function tags(): array\n    {\n        return ['invoice:'.$this-&gt;invoice-&gt;id, 'user:'.$this-&gt;invoice-&gt;user_id];\n    }\n\n    public function handle(): void\n    {\n        \/\/ send it\n    }\n}\n<\/code><\/pre>\n<p>Now when a customer emails me at 11pm asking why they didn&rsquo;t get their receipt, I paste their user ID into the Horizon search and see every job ever queued for them, passed or failed. I used to grep production logs for this. I lost hours doing that.<\/p>\n<p>For genuine failure alerts, I wire the <code>JobFailed<\/code> event into a small notification bus. Horizon&rsquo;s own notifications config handles wait-time and long-wait alerts. Anything else routes through my normal error stack. If you want the wider picture of how I structure that side of a Laravel app, <a href=\"https:\/\/abrarqasim.com\/blog\/laravel-octane-frankenphp-what-actually-changed\" rel=\"noopener\">my Laravel Octane and FrankenPHP notes<\/a> cover the request side.<\/p>\n<h2 id=\"redis-is-doing-the-actual-work\">Redis is doing the actual work<\/h2>\n<p>Worth remembering: Horizon isn&rsquo;t magic. Everything you see in the dashboard is Redis lists, Redis sorted sets, and a small metrics snapshot table. The queue itself is a Redis list that workers pop off using <a href=\"https:\/\/redis.io\/docs\/latest\/develop\/data-types\/lists\/\" rel=\"nofollow noopener\" target=\"_blank\">blocking list operations<\/a>, moving jobs onto a reserved list until they finish. If a worker dies mid-job, Redis doesn&rsquo;t recover it for you. The reserved job sits there until a supervisor process reclaims it after its visibility timeout.<\/p>\n<p>Which is why the winter incident I mentioned happened. My Redis eviction policy was <code>allkeys-lru<\/code>, my instance was memory-pressured, and Redis quietly dropped keys that Horizon expected to still exist. I switched to <code>noeviction<\/code> on the queue Redis and moved cache to its own instance. That&rsquo;s not a Horizon lesson, it&rsquo;s the lesson Horizon forced me to learn about my stack.<\/p>\n<h2 id=\"observability-i-actually-use\">Observability I actually use<\/h2>\n<p>The built-in metrics view is good, but I still export three things to Grafana via a tiny scheduled command.<\/p>\n<p>Oldest waiting job age per queue. This is my alert signal, not queue depth. Failed job rate over the last hour. Average job runtime per class name, so I can spot regressions when a payload changes and jobs quietly get slower.<\/p>\n<p>You can pull these from Horizon&rsquo;s Redis metrics directly, or use one of the community Prometheus exporters. If you&rsquo;re already on Sentry, <a href=\"https:\/\/docs.sentry.io\/product\/insights\/queue-monitoring\/\" rel=\"nofollow noopener\" target=\"_blank\">their queue monitoring product<\/a> handles the alerting end without the Grafana glue, which is what I set up for my consulting work these days. There&rsquo;s a running list of that work over on <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">my portfolio<\/a> if you want to see the shape of it.<\/p>\n<h2 id=\"when-i-still-reach-for-raw-workers\">When I still reach for raw workers<\/h2>\n<p>Two cases.<\/p>\n<p>Very small apps where I don&rsquo;t want the Horizon dashboard as an operational surface at all. If the whole app is one queued email an hour, <code>queue:work<\/code> behind a systemd unit is fewer moving parts and one less package to keep patched.<\/p>\n<p>Long-running data pipelines that I&rsquo;d rather run as a real batch job. Horizon can technically handle 30-minute jobs, but the observability lies to you: a job running for 25 minutes looks the same as a stuck one until it isn&rsquo;t. For anything I&rsquo;d call a pipeline I use a separate runner and log to my normal ingestion path instead of the queue.<\/p>\n<p>For the everyday Laravel 12 app with mixed workloads, though, I don&rsquo;t run raw workers anymore. Horizon paid for itself the day I stopped writing my own dashboards. If you&rsquo;re catching up on other things that shipped in that release, <a href=\"https:\/\/abrarqasim.com\/blog\/laravel-12-new-features-what-actually-changed\" rel=\"noopener\">my Laravel 12 features roundup<\/a> has the rest.<\/p>\n<h2 id=\"what-to-do-this-week\">What to do this week<\/h2>\n<p>If you&rsquo;re already on Laravel 11 or 12, spend a couple of hours doing this.<\/p>\n<ol>\n<li><code>composer require laravel\/horizon<\/code>, publish the config, set up two supervisors like the shape above. One for short jobs, one for long ones.<\/li>\n<li>Add a <code>tags()<\/code> method to your five loudest jobs. Search for a real customer ID in the dashboard and see what falls out.<\/li>\n<li>Turn on Horizon&rsquo;s built-in wait-time notification for at least the queue you care about most.<\/li>\n<li>Change your alert from <code>queue depth &gt; N<\/code> to <code>oldest waiting job age &gt; N seconds<\/code>. Depth lies. Age doesn&rsquo;t.<\/li>\n<\/ol>\n<p>That&rsquo;s it. You&rsquo;ll have caught something in the first week you would&rsquo;ve missed. And you&rsquo;ll finally know what your workers are actually doing at 3am, which is the whole point.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>I ran Laravel queues on queue:work + supervisord for two years, then a 3am alert made me switch to Horizon. Here&#8217;s the config that actually stuck.<\/p>\n","protected":false},"author":2,"featured_media":458,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"I ran Laravel queues on queue:work + supervisord for two years, then a 3am alert made me switch to Horizon. Here's the config that actually stuck.","rank_math_focus_keyword":"laravel horizon","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[147,52],"tags":[49,80,56,511,329,53,175,307],"class_list":["post-459","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-backend","category-php","tag-backend","tag-devops","tag-laravel","tag-laravel-horizon","tag-observability","tag-php","tag-queues","tag-redis"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/459","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=459"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/459\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/458"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=459"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=459"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=459"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}