negoziator/horizon-ui drops a fully-featured Horizon dashboard into any Laravel + Inertia application. It is rendered via Inertia (Vue 3) and exposes a complete REST API for queue management — all without requiring Ziggy or Wayfinder.
Features at a glance:
- Live stats: jobs/min, failures, process count, paused supervisors
- Queue metrics and per-supervisor workload
- Recent/failed/pending job browser with retry, forget, and bulk flush
- Batch browser with retry and cancel
- Full-text job search across class name, queue, tags, and payload content
viewHorizonUigate for fine-grained access control- Optional
horizon-ui:auto-pausecommand that pauses idle supervisors automatically - Publishable Vue components for full frontend customization
| Dependency | Version |
|---|---|
| PHP | ≥ 8.4 |
| Laravel | ^11.0 | ^12.0 | ^13.0 |
| Laravel Horizon | ^5.0 |
| inertiajs/inertia-laravel | ^1.0 | ^2.0 | ^3.0 |
| Frontend | |
| Vue | ^3.0 |
| Tailwind CSS | v4 |
| reka-ui | ^2.0 |
| lucide-vue-next | ^0.400+ |
Tailwind v4 only. The bundled components use v4 utility classes. If your app runs Tailwind v3 you will need to publish and adjust the components.
composer require negoziator/horizon-ui
php artisan horizon-ui:installhorizon-ui:install publishes config/horizon-ui.php, copies the Vue components to resources/js/vendor/horizon-ui/, and prints the dashboard URL.
The bundled Vue components require reka-ui and lucide-vue-next. Install them alongside your other frontend dependencies:
npm install reka-ui lucide-vue-nextThe bundled components use dark: utility variants, but Tailwind v4 does not register a dark variant out of the box — you have to declare one yourself. Add it to your Tailwind entry point (typically resources/css/app.css) so the dashboard responds to OS-level dark-mode preference:
@import "tailwindcss";
@custom-variant dark (@media (prefers-color-scheme: dark));If your app uses a class-based dark-mode toggle (<html class="dark">) instead, declare the variant accordingly:
@custom-variant dark (&:where(.dark, .dark *));Without one of these declarations, the dashboard renders in light mode regardless of OS preference.
The HorizonDashboard Inertia component is placed in resources/js/vendor/horizon-ui/pages/ (done automatically by horizon-ui:install). Vite's import.meta.glob doesn't scan that path by default, so you need to add it to your resolve function in app.ts (or app.js):
// at the top of app.ts:
// import type { DefineComponent } from 'vue';
resolve: async (name) => {
const vendorPages = import.meta.glob<DefineComponent>('./vendor/horizon-ui/pages/**/*.vue');
const vendorPath = `./vendor/horizon-ui/pages/${name}.vue`;
if (vendorPath in vendorPages) {
return await resolvePageComponent(vendorPath, vendorPages);
}
return await resolvePageComponent(
`./pages/${name}.vue`,
import.meta.glob<DefineComponent>('./pages/**/*.vue'),
);
},Why not
???resolvePageComponentthrows when a component isn't found, so the??operator never gets to evaluate the fallback. Theincheck is required.
After publishing (vendor:publish --tag=horizon-ui-vue), or after a package update where you want to pull in new component versions, re-run the publish command. Your edited copies are never overwritten without --force.
After publishing the config file you will find config/horizon-ui.php:
return [
// URL path for the dashboard
'path' => 'horizon-ui',
// Middleware applied to all routes (page + API)
'middleware' => ['web', 'auth'],
// Inertia component name — override to use your own page
'view' => 'HorizonDashboard',
// Set false to disable the dashboard page (API-only usage)
'register_dashboard_route' => true,
// Set false to disable all API routes
'register_api_routes' => true,
// Frontend polling interval in milliseconds
'polling_interval' => 2000,
// Auto-pause idle supervisors via the scheduler
'auto_pause' => [
'enabled' => false,
],
// Job search: max jobs scanned per request
'search' => [
'scan_limit' => 1000,
],
];The package defines a viewHorizonUi gate that defaults to local environments only. Override it in your AppServiceProvider to fit your app's access rules:
use Illuminate\Support\Facades\Gate;
Gate::define('viewHorizonUi', fn ($user) => $user->isAdmin());To enforce the gate at the routing layer, add it to the middleware list in config/horizon-ui.php:
'middleware' => ['web', 'auth', 'can:viewHorizonUi'],The HorizonDashboardView contract drives all dashboard data. Bind your own implementation to customise what is shown:
use Negoziator\HorizonUi\Contracts\HorizonDashboardView;
$this->app->bind(HorizonDashboardView::class, MyCustomDashboardView::class);Your implementation must satisfy the five methods defined in the contract: stats(), queueMetrics(), recentJobs(), supervisors(), and recentBatches().
Publish the Vue components to make frontend changes:
php artisan vendor:publish --tag=horizon-ui-vueThis copies the five components to resources/js/vendor/horizon-ui/. Edit them freely — they will no longer be overwritten on package updates.
The package does not use Ziggy or Wayfinder. All API route URLs are built server-side in HorizonDashboardController::buildRouteMap() and passed to the page as an Inertia routes prop. Components receive the prop and call URLs directly:
router.post(props.routes.pause, {}, { preserveScroll: true })If you add custom API routes, extend buildRouteMap() in a subclass or override the controller binding.
The package exposes a search endpoint that scans jobs in PHP and filters across class name, queue name, tags, and the decoded payload:
GET /{path}/api/jobs/search
| Parameter | Type | Default | Description |
|---|---|---|---|
q |
string | — | Search term (required, min 2 chars) |
type |
string | recent |
Job set: recent, failed, pending, completed |
queue |
string | — | Restrict to a specific queue name |
limit |
int | search.page_size |
Max results to return (max 100) |
cursor |
int | 0 |
Offset to resume from (use next_cursor from the previous response) |
The response includes a next_cursor value for fetching the next page; it is null when results are exhausted.
The jobSearch URL is included in the Inertia routes prop so Vue components can call it directly:
axios.get(props.routes.jobSearch, { params: { q: 'SendEmail', type: 'failed' } })The search fetches jobs from Horizon's Redis sorted sets in pages of 50 (Horizon's fixed page size), stopping once the requested number of results is found or the configured scan ceiling is reached. For large queues, keep queries specific and use the queue filter to narrow the scan.
Both the default page size and the scan ceiling are configurable in config/horizon-ui.php:
'search' => [
'page_size' => 25, // default results per request (overridable via ?limit=)
'scan_limit' => 1000, // max jobs scanned per request
],For installations with tens of thousands of jobs, document that search is intended for development and small-to-medium production queues. Very large queues may need an external index (e.g. Redis Search).
When auto_pause.enabled is true, the package schedules horizon-ui:auto-pause every minute. The command checks each supervisor's queues and pauses supervisors whose queues have been empty for a configurable period, then resumes them when jobs arrive again.
You can also run it manually:
php artisan horizon-ui:auto-pausecomposer install
./vendor/bin/pestPull requests are welcome. Please open an issue first for significant changes.
The MIT License (MIT). Please see License File for more information.