vdlp/oc-horizon-plugin
Integrates Laravel Horizon into October CMS so you can monitor and manage Redis-backed queues from Horizon's dashboard, with job metrics, retries, and workload insights available inside your October CMS project.
Confirm the premise before installing anything. Horizon only manages Redis-backed queues. If your October CMS install runs QUEUE_CONNECTION=database or sync, this plugin gives you a dashboard that never shows a job. Verify Redis reachability and the queue connection first:
QUEUE_CONNECTION=redis
REDIS_CLIENT=phpredis
Then install:
composer require vdlp/oc-horizon-plugin
php artisan october:migrate
Confirm Vdlp\Horizon\Plugin ends up in config/plugins.php, and that the Horizon entry appears in the October backend navigation.
Where to look first, in order:
composer.json of the package — the require block tells you which laravel/horizon, laravel/framework, and october/* versions this adapter was built against. This is your compatibility contract, and it's the only reliable one given how thin the docs are.Plugin.php — it's the whole adapter. Read register() and boot() to see what actually gets wired: the Horizon service provider, the backend nav/settings registration, and (critically) the authorization binding. Everything the plugin does beyond stock Horizon lives here.config/horizon.php — after publishing, this is the real source of truth for supervisors, queues, balancing, and metrics waits. The plugin is a wrapper; your day-to-day work happens in this file.First use case (about ten minutes): add one supervisor block to config/horizon.php for the default queue, run php artisan horizon in a terminal, dispatch a throwaway job (SomeJob::dispatch()->onQueue('default')), and open the Horizon dashboard in the October backend. Watch it move Pending → Processing → Completed. Then make a job throw on purpose and confirm it lands in Failed with a working retry button. If that loop works, the integration is sound and everything else is tuning.
One caveat worth stating up front: the repository is archived and has a small user base. That doesn't make it unusable — it's a thin wrapper over first-party Laravel Horizon, so the underlying machinery is well-maintained — but it does mean you should expect to read source rather than documentation, and that October/Horizon version bumps may leave you waiting indefinitely for a compat fix.
Run horizon, not queue:work. This is the single most important operational decision. In production, put the Horizon master under systemd or supervisord:
[program:horizon]
command=php /var/www/artisan horizon
autostart=true
autorestart=true
user=www-data
stdout_logfile=/var/log/horizon.log
Locally, run php artisan horizon in a terminal too — the same command means the dashboard reflects exactly what production reflects, and you avoid the "works under queue:work, behaves differently under Horizon" class of confusion.
Every deploy must recycle Horizon. Horizon boots the full October application into memory once and holds it. New code on disk is invisible until you terminate:
php artisan down
composer install --no-dev --optimize-autoloader
php artisan october:migrate
php artisan config:cache
php artisan horizon:terminate
php artisan up
horizon:terminate signals the master to drain gracefully and respawn supervisors — it is not interchangeable with queue:restart. Putting it in the deploy script (rather than remembering it manually) is the difference between a working deploy pipeline and one that silently serves stale code for a week.
Split workloads onto named queues early. This is where Horizon earns its keep day-to-day: per-queue wait-time graphs tell you which workload is backing up, and that's only true if you actually separate them.
public function viaQueue(): string { return 'mail'; }
// or
SendInvoice::dispatch($invoice)->onQueue('mail');
Then give each queue its own supervisor block so you can scale and time them independently.
Scope supervisors by environment. Keep the environments keys in config/horizon.php matching APP_ENV exactly. This gives you one lightweight worker locally and a fleet in production from a single file:
'environments' => [
'production' => [
'supervisor-default' => [
'connection' => 'redis',
'queue' => ['default'],
'balance' => 'auto',
'minProcesses' => 1,
'maxProcesses' => 10,
'maxTime' => 3600,
'maxJobs' => 1000,
'memory' => 256,
'tries' => 3,
'timeout' => 120,
],
'supervisor-mail' => [
'connection' => 'redis',
'queue' => ['mail'],
'balance' => 'simple',
'processes' => 4,
'maxTime' => 3600,
],
],
'local' => [
'supervisor-default' => [
'connection' => 'redis',
'queue' => ['default', 'mail'],
'balance' => 'simple',
'processes' => 1,
'tries' => 1,
'timeout' => 60,
],
],
],
Schedule the metrics snapshot. Without it, the throughput and runtime graphs stay empty forever and you'll waste an afternoon assuming the plugin is broken:
// Plugin.php registerSchedule()
$schedule->command('horizon:snapshot')->everyFiveMinutes();
Bind authorization to October's user model. Stock Horizon is wide open unless a gate is registered. Horizon's operational surface includes retry, delete, purge, and supervisor termination — behind a web route. The plugin normally wires this, but verify it against your own backend users rather than trusting it:
// Plugin.php boot()
Horizon::auth(fn ($request) => BackendAuth::getUser()?->is_superuser === true);
Use tags and wait thresholds to make failures diagnosable. Tags let you find every job belonging to one customer or invoice in the dashboard; wait thresholds turn slow queues into visible alerts:
public function tags(): array
{
return ['invoice', 'customer:'.$this->invoice->customer_id];
}
'waits' => [
'redis:default' => 60,
'redis:mail' => 300,
],
Test with fakes, not with the dashboard. Assert queue routing in tests — Queue::fake() plus assertPushedOn('mail', SendInvoice::class) — rather than trying to scrape or assert on Horizon's UI. The dashboard is an operational tool, not a test surface.
config/horizon.php is cached. Editing supervisors does nothing until php artisan config:clear (dev) or config:cache (prod) and horizon:terminate. A stale config cache is the number one reason a new supervisor never starts — check this before you start debugging the plugin.
An environments key mismatch silently degrades everything. production vs prod, or trailing whitespace in APP_ENV, makes Horizon fall back to defaults: one worker, no balancing, confusing graphs. Log config('app.env') from a worker if you suspect it.
Redis down is a silent failure. Horizon starts fine, logs connection errors, and renders an empty dashboard rather than failing loudly. If the dashboard is blank but jobs are piling up, run redis-cli ping before touching the plugin or its config.
Order your timeouts correctly. The invariant is job timeout < supervisor timeout < retry_after (the last one lives in config/queue.php). Violating it produces duplicate job execution under load — the classic Horizon bug report, and one that looks like an application bug for a long time.
Long-running workers drift. Workers boot the full October app once; memory accumulates and DB connections time out (MySQL server has gone away). Always set memory, maxJobs, and maxTime explicitly on each supervisor rather than trusting defaults. October's larger bootstrap makes this more pressing than in a plain Laravel app.
Asset publishing drifts after upgrades. Horizon ships JS/CSS into the public path; after a package update, republish with php artisan vendor:publish --tag=horizon-assets --force or the dashboard may 404 its bundle. If the dashboard URL prefix changed between majors, php artisan route:list shows you where it actually lives.
Publishing Horizon config overwrites your tuning. vendor:publish --tag=horizon-config replaces your carefully balanced supervisors. Copy the stub to a scratch path, diff against yours, and merge by hand — never publish straight over a tuned config.
Migrations are shared with stock Laravel. Horizon adds and changes failed_jobs / job_batches schema over time. After any upgrade, run php artisan october:migrate, or the first failed job after the upgrade throws a runtime exception at the worst possible moment.
The plugin is archived and lightly adopted. Treat version upgrades as unreviewed until you've read the actual diff — the 3.4.0 notes contain only a compare link, with no indication of whether it's a compatibility bump, a fix, or a breaking change. Since this plugin wraps a Laravel package, a bad bump can surface as an application-wide boot failure rather than an isolated dashboard break. Pin explicitly ("vdlp/oc-horizon-plugin": "3.3.*") until a staging run confirms behavior, and roll back with composer require plus a config cache clear.
Extension points are deliberately thin. Prefer Horizon's own hooks (Horizon::routeMailNotificationsTo(), routeSlackNotificationsTo()) and Laravel job middleware over patching the plugin. For per-tenant or per-backend-user dashboards you'll be writing a gate, not subclassing anything — and given the maintenance posture, staying on stock Horizon surfaces is the safer long-term position.
Ask who owns the Horizon version. If the plugin pins laravel/horizon transitively, an upgrade to Horizon elsewhere in the app may conflict. If it inherits yours, you own the upgrade and the compatibility risk that comes with it. Resolve this question before you're forced to.
How can I help you explore Laravel packages today?