Extending the Horizon Dashboard: Adding a UI to a Compiled Vue Bundle
· 19 min read · Boring Observability
Verified against Laravel 13 · Horizon 5.x · knobik/laravel-horizon-job-output
Horizon's dashboard is a compiled Vue application with no plugin API. If you want to add a panel to the job details page, the usual options are to fork the package or to publish its assets and patch the bundle, and both mean redoing the work on every release. knobik/laravel-horizon-job-output takes a third approach that needs neither.
The package gives a queued job the same output API an Artisan command has ($this->info(),
table(), withProgressBar()) and streams that output onto Horizon's job details page while the
job runs. It also adds a Reserved Jobs page to the sidebar and a Cancel Batch card to the batch screen. None of this
forks Horizon, publishes its assets or modifies Horizon's code.
This article walks through how it does that. The same techniques apply to any package that extends a Laravel package whose frontend ships pre-compiled.
Every technique below comes from knobik/laravel-horizon-job-output, by knobik, MIT licensed. The code quoted in this article is theirs, abridged for length. We are writing it up because it solves cleanly a problem most people solve by forking. If any of it is useful to you, star the repo.
Key takeaways#
- Get your data in through the repository, not a new endpoint.
RedisJobRepository::$keysis a public whitelist read withHMGET. Append a field to it and that field flows through Horizon's existing/api/jobs/{id}response with no route or controller override. - Override the layout view, then render the original from a second namespace.
addNamespace('horizon-original', …)plusprependNamespace('horizon', …)lets you patch Horizon's real rendered HTML instead of shipping a copy of it that drifts. - Mount inside Vue's in-DOM template, not into Vue-owned DOM. A
<div>spliced in after<router-view></router-view>compiles to a static node: Vue renders it once and never patches it again, so your plain JavaScript owns it safely. - Horizon's catch-all route gives you SPA routes for free. It serves the layout for any path under the dashboard prefix, so a URL the compiled router has no route for renders an empty router view, which leaves your mount as the only thing on the page.
- Register routes in
register(), notboot(). Every provider'sregister()runs before any provider'sboot(), which is the only placement that beats Horizon's catch-all regardless of package discovery order. - Assume every anchor will move. Each patch is independently optional, logs what the dashboard will be missing, and a scheduled CI job runs the suite against
laravel/horizon:dev-masterso drift arrives as a warning rather than a bug report.
The problem: a dashboard with no seams#
Horizon ships its frontend as a compiled bundle in public/vendor/horizon, mounted by a Blade layout that
is little more than one <div id="horizon"> containing a <router-view>, a sidebar
and a script tag. There is no Horizon::registerPanel(), no view slot and no JavaScript event bus.
That leaves three usual options:
| Approach | Cost |
|---|---|
| Fork Horizon | You now maintain a queue dashboard. Every upstream release is a merge. |
| Publish and patch the assets | Your patch is a build artifact in public/. horizon:publish overwrites it on the next deploy without warning. |
| Ship a copy of the layout view | Works until Horizon changes its layout, at which point your users get the old dashboard with none of the new features and no error to explain it. |
The package does none of these. It uses Horizon's rendered HTML as the extension point and treats every assumption about that HTML as provisional: it patches the render output, not the source. The rest of the design follows from that decision.
Getting your data into Horizon's API#
The output has to reach the browser first. The obvious way is a new endpoint,
GET /horizon/api/job-output/{id}, with your own controller and your own Redis read. The package avoids
adding one.
Horizon reads each job out of Redis in RedisJobRepository, and it does not use HGETALL. It
reads a fixed whitelist of hash fields with HMGET, and that whitelist is a public property:
// Knobik\HorizonJobOutput\HorizonJobOutputServiceProvider
protected function exposeOutputOnJobRepository(object $repository): void
{
if (! property_exists($repository, 'keys')) {
return;
}
if (! in_array(JobOutputStore::FIELD, $repository->keys, true)) {
$repository->keys[] = JobOutputStore::FIELD;
}
}
The repository is a singleton, so appending output to it once at boot applies process-wide. From then on,
Horizon's own /api/jobs/{id} endpoint returns the field alongside status,
payload and the rest. The dashboard makes no second request, and there is no new route or controller
whose authorization could be wrong: the data comes back from an endpoint Horizon already gates.
Storage follows the same approach. The output is written as a field on Horizon's own job hash rather than under a key of its own:
// Knobik\HorizonJobOutput\RedisJobOutputStore
public function put(string $jobId, string $output): bool
{
$connection = $this->connection();
if (! $connection->exists($jobId)) {
return false;
}
$connection->hset($jobId, self::FIELD, $output);
return true;
}
Sharing the key means sharing its TTL. Horizon already trims completed, failed and recent jobs on the schedule set by
horizon.trim.*, and because the output is a field on that same hash, the same policy trims it. There is no
cleanup command to run and no way for the two lifetimes to drift apart; a separate key would have needed a retention
policy of its own. The exists() guard is what keeps this safe: HSET on a missing key would
create a new hash with no expiry, a leak that grows by one key per job and never shrinks.
Owning the layout without forking it#
Laravel's view finder resolves horizon::layout through a namespace hint. A package that registers its own
view directory under the horizon namespace with prependNamespace() wins that lookup. That
part is well known. The problem is that you then have to supply a layout, which usually means copying Horizon's and
keeping the copy in sync with every release.
The package avoids that by keeping Horizon's own view path reachable under a second name before taking over the first:
// Knobik\HorizonJobOutput\HorizonJobOutputServiceProvider
protected function registerViewOverride($view): void
{
$finder = $view->getFinder();
$hints = $finder->getHints();
if (! isset($hints['horizon'])) {
return;
}
$finder->addNamespace('horizon-original', $hints['horizon']);
$finder->prependNamespace('horizon', __DIR__.'/../resources/views');
}
The override view is then three lines, none of them Horizon's markup:
{!! app(\Knobik\HorizonJobOutput\LayoutDecorator::class)->decorate(
view('horizon-original::layout', ['isDownForMaintenance' => $isDownForMaintenance])->render()
) !!}
Horizon renders its real layout, and the decorator receives the resulting HTML string and splices things into it. A Horizon release that changes the sidebar, the theme switcher or the asset URLs changes them here too, because this is Horizon's layout with a few extra nodes added.
Two more details are worth copying. The registration runs from $this->app->booted() and through
callAfterResolving('view', …), so a request that never renders a view never pays to construct Blade's
finder. And every splice goes through one method, which logs a missing anchor and returns the HTML unchanged:
// Knobik\HorizonJobOutput\LayoutDecorator
protected function patch(string $html, string $anchor, string $insert, string $missing, bool $before = false, int $from = 0): string
{
$position = strpos($html, $anchor, $from);
if ($position === false) {
$this->warn($anchor, $missing); // logs what the dashboard will be missing
return $html;
}
return substr_replace($html, $insert, $before ? $position : $position + strlen($anchor), 0);
}
Each caller passes its own $missing string, such as "the output panel will not be shown", "the Reserved
Jobs link will be missing" or "nothing this package adds will load". If a Horizon release moves an anchor, the result
is a log line naming the feature that disappeared, rather than a 500 on the dashboard or a blank panel with nothing in
the logs.
Where to put a mount point in a Vue app you don't control#
The usual approach is to wait for the SPA to render and then appendChild into it. That puts your node
inside DOM that Vue's virtual DOM believes it owns, and the next patch (a poll updating the job status, or a route
change) either removes it or leaves Vue diffing against a tree that no longer matches.
The package inserts its mounts server-side, into the HTML string, immediately after Horizon's router view:
protected const ROUTER_VIEW_ANCHOR = '<router-view></router-view>';
// ...
return $this->patch($html, self::ROUTER_VIEW_ANCHOR, $mounts, 'the output panel will not be shown');
That position puts the <div id="hjo-root"> inside #horizon, which Vue uses as its
in-DOM template. Vue compiles the element's existing markup into its render function, and a node with no directives,
bindings or interpolation compiles to a static node: rendered once, then skipped by every later patch. So the mount
sits inside the app, at the right place in the layout, and the diff never touches it. Plain JavaScript can own it.
The scripts and styles go just before </body>, outside #horizon, so Vue never tries to
compile them.
If you need to add DOM to a compiled SPA you don't control, add it to the template the app compiles from, before the app boots, and not to the DOM the app has already rendered. Markup in the template becomes a static node the framework leaves alone. A node appended to rendered DOM can be removed by the next re-render.
Adding a whole page the compiled router has never heard of#
The package also adds a new page, Reserved Jobs at /horizon/reserved, to a compiled router that has no
route for it.
This works because Horizon's dashboard routes end in a catch-all GET route that matches every path under the prefix
and returns the same layout. So /horizon/reserved already serves the app. The compiled Vue router finds no
route matching that path and renders an empty <router-view>, which leaves the package's own mount,
placed immediately after it, as the only content in the column.
The sidebar link differs from Horizon's own nav markup in one way:
<li class="nav-item">
<a href="{$href}" class="nav-link d-flex align-items-center" data-hjo-nav>
<svg …></svg>
<span>Reserved Jobs</span>
</a>
</li>
It is a plain <a href>, not a <router-link>. The nav is inside
#horizon, so Vue compiles whatever is put there, and a router-link pointing at a route the
bundle does not know about resolves to nothing. A plain href performs a full navigation, and Vue leaves it alone. The
href is built from horizon.proxy_path and horizon.path, mirroring how Horizon's own bundle
computes its base path, so the link still works with a custom dashboard path or behind a reverse proxy.
Registering routes Horizon's catch-all won't swallow#
The Reserved Jobs page needs an API endpoint, and that endpoint lives under the same prefix as the catch-all. When two routes match, the one registered first wins.
Horizon adds its catch-all from boot(), so the package registers its routes from
register():
public function register(): void
{
$this->mergeConfigFrom(__DIR__.'/../config/horizon-job-output.php', 'horizon-job-output');
// ... bindings ...
// Registered here rather than in boot(). Horizon's dashboard ends in a
// catch-all route matching everything under its prefix, added from its
// own boot(), and whichever route is registered first wins. Every
// provider's register() runs before any provider's boot(), so this is
// the only placement that beats the catch-all no matter what order the
// packages were discovered in.
$this->registerRoutes();
}
You don't control package discovery order, but the container guarantees that every register() runs before
any boot(), so a route registered in register() always comes first.
The cost is that Horizon has not booted yet, so its route group cannot be reused and has to be rebuilt, middleware stack included:
protected function middleware(): array
{
$middleware = (array) config('horizon.middleware', ['web']);
if (class_exists(SentinelMiddleware::class)) {
array_unshift($middleware, SentinelMiddleware::class.':horizon');
}
return $middleware;
}
Newer Horizon versions collect this into a named horizon middleware group, but that group does not exist
across the whole ^5.0 range the package supports, and naming a group that was never registered makes the
router try to resolve a class by that name. Rebuilding the list works on every version in the range.
Authorization needs more care. Horizon attaches its Authenticate middleware in its base controller, not on
the route group, so a controller that does not extend that base class gets no authorization check. The package applies
the middleware explicitly, around the whole route file rather than per route:
// routes/reserved-jobs.php
Route::middleware(Authenticate::class)->group(function () {
Route::get('/api/reserved-jobs', [ReservedJobsController::class, 'index']);
Route::post('/api/reserved-jobs/release', [ReservedJobsController::class, 'release']);
});
If you are writing your own Horizon extension, check where the package you are extending applies its gate. A route
group under horizon.middleware alone gets you the web stack and nothing else, so your endpoint
would be open to anyone who can reach the dashboard's URL, whether or not they pass the viewHorizon gate.
The feature toggles are enforced in the controllers, not around route registration:
public function index(): array
{
abort_unless(config('horizon-job-output.reserved_page', true), 404);
return ['jobs' => $this->reserved->all()->all()];
}
Gating the registration would bake the setting into a cached route table, so changing the config would have no effect
until someone ran route:clear. Checking in the controller avoids that.
Reacting to navigation without access to the router#
The panel has to know when the user navigates to a job details page. It cannot ask the router, because the router is inside a bundle the package has no reference to. Vue Router in history mode pushes state rather than reloading, so the package wraps the two history methods it calls and re-announces them as an ordinary DOM event:
['pushState', 'replaceState'].forEach((method) => {
const original = history[method];
history[method] = function () {
const result = original.apply(this, arguments);
window.dispatchEvent(new Event('hjo:navigated'));
return result;
};
});
function onNavigation(sync) {
window.addEventListener('hjo:navigated', sync);
window.addEventListener('popstate', sync);
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', sync);
} else {
sync();
}
}
The browser fires popstate for the back and forward buttons; the wrapper covers navigation the app
triggers itself, which does not fire it. This code lives in a shared support module concatenated ahead of the feature
scripts, so the history methods are wrapped once however many features are enabled, instead of by whichever feature
script happened to load first.
The current screen is then read from the URL, since every Horizon screen has its own path:
function currentJobId() {
const path = support.dashboardPath();
const preview = path.match(/^\/jobs\/[^/]+\/([^/]+)\/?$/);
if (preview) {
return preview[1];
}
const failed = path.match(/^\/failed\/([^/]+)\/?$/);
if (failed) {
return failed[1];
}
return null;
}
Because Vue creates the mount point when it compiles the layout template, on a cold load this code can run before the
element exists. whenElementExists retries up to 50 times at 100ms intervals instead of assuming a boot
order.
Shipping frontend assets with no build step#
A package extending a compiled dashboard cannot add itself to that dashboard's build. Publishing files into
public/ works, but it needs a publish step in every deploy, and Horizon's own horizon:publish
writes to the same directory.
So everything is inlined into the layout at render time: one <style>, one
<script type="module">, and a settings object serialised with Js::from(). That is the
same helper Horizon uses for its own settings, and it applies the escaping needed to embed data inside a script tag.
The terminal renderer takes more work. The package vendors an xterm.js build so a progress bar redraws in place on the dashboard as it would in a shell. An ESM build ends in an export statement, and nothing can import from an inline module, so the export does nothing there. The package rewrites it into a global assignment as it inlines the file:
protected const EXPORT_PATTERN = '/export\s*\{\s*(\w+)\s+as\s+Terminal\s*\}\s*;?/';
// The export is the last statement in the bundle, so only the tail is
// searched. Running the pattern over the whole 345KB build would repeat
// that scan on every dashboard request for no added certainty.
$tail = substr($js, -self::TAIL_BYTES);
if (! preg_match(self::EXPORT_PATTERN, $tail, $matches, PREG_OFFSET_CAPTURE)) {
Log::warning('[horizon-job-output] Could not rewrite the xterm export, so the terminal renderer was skipped. …');
return ['css' => '', 'js' => ''];
}
$js = substr_replace($js, 'globalThis.HorizonJobOutputTerminal = '.$matches[1][0].';', /* … */);
Rewriting a vendored bundle with a regular expression is brittle, and the package handles it accordingly. The pattern runs over the last 512 bytes only, a miss is logged, and the panel falls back to an HTML renderer that collapses the control sequences and needs no extra payload. A failed rewrite costs you the xterm renderer, not the output panel.
Capturing output from a job that is already running#
The dashboard side needs output to show, and getting an output object into a running job has problems of its own.
It cannot happen at dispatch. A queued job is serialised, and an unserialised object never runs its constructor, so anything the constructor attached is gone by the time the worker has it. The attachment has to happen at execution time, inside the worker, which is what a global bus pipe allows:
protected function registerBusPipe(): void
{
$dispatcher = $this->app->make(BusDispatcherContract::class);
if (! $dispatcher instanceof BusDispatcher) {
return;
}
try {
$property = new ReflectionProperty($dispatcher, 'pipes');
$pipes = (array) $property->getValue($dispatcher);
} catch (Throwable) {
$pipes = [];
}
if (in_array(CaptureJobOutput::class, $pipes, true)) {
return;
}
$pipes[] = CaptureJobOutput::class;
$dispatcher->pipeThrough($pipes);
}
pipeThrough() replaces the pipe list outright and there is no getter, so the package reads the existing
pipes by reflection and appends to them. Replacing the list instead would drop the pipes other packages have
registered, with no error.
Two cases need their own handling:
-
Artisan commands run inside a job. The console kernel writes a command's output to whatever buffer
it is handed and discards it when handed nothing. So the kernel is decorated for the length of the job (its
call()supplies the job's output as the default buffer) and restored in afinally, because a worker handles one job after another in the same process and a stale decorator would feed a finished job's output. The facade's resolved instance is cleared alongside the binding, sinceArtisan::call()is how a job runs a command in practice and a facade holds on to whatever it resolved first. -
Queued Artisan commands.
Artisan::queue()dispatches aQueuedCommand, which does not useInteractsWithQueue, so nothing ever sets a job on it and the bus pipe has no way to reach the Horizon id its output belongs on. The package listens toQueue::before()/after(), keeps the job the worker has in hand in a small singleton, and only hands out its id when the payload'scommandNamematches the command being piped. That check stops a command dispatched inside another job from writing over the outer job's output.
The write path is buffered with a flush interval, capped at max_bytes, and flushed with
force: true from a finally. A job that throws keeps whatever it wrote before the exception,
which is usually the output you most want to read.
Designing for the day Horizon changes#
Everything above depends on internals that carry no compatibility guarantee: a public property on a repository, a private property read by reflection, two string anchors in rendered markup, and the shape of a trailing export in a vendored bundle. Any Horizon release could break one of them, so the package is built to make that breakage cheap and visible.
Every patch is independently optional: a missing anchor costs you that one feature and logs which one. Every failure is
a Log::warning naming the anchor and the consequence, so the first person to hit it can diagnose it without
reading the package source. And a scheduled CI job tests against Horizon's development branch:
# .github/workflows/horizon-canary.yml
on:
schedule:
- cron: '41 6 * * 1'
# ...
- name: Install Horizon from its development branch
run: |
composer require --no-update "laravel/horizon:dev-master"
composer update --no-interaction --prefer-dist
It runs the full suite against laravel/horizon:dev-master once a week and, on failure, opens a labelled
issue listing which four internals might have moved. Upstream drift shows up as an issue on a Monday morning, not as a
user's bug report after a release.
The pattern, generalised#
The same mechanisms work for extending any Laravel package that ships a compiled frontend:
| You want to… | Mechanism | What it depends on |
|---|---|---|
| Add a field to an existing API response | Mutate the repository's field whitelist at boot | The property staying public |
| Add markup to a view you don't own | Re-register the original under a second namespace, prepend your own, render and patch | String anchors in the rendered HTML |
| Own DOM inside a compiled SPA | Splice a bare element into the in-DOM template server-side; it compiles to a static node | The framework not patching static nodes |
| Add a page to a compiled router | Use the host's catch-all route; render into your own mount when the router matches nothing | A catch-all existing at all |
| Beat a catch-all route | Register from register(), not boot() |
Nothing; the container guarantees the order |
| Observe SPA navigation | Wrap history.pushState/replaceState, re-dispatch as an event, plus popstate |
History-mode routing |
| Attach state to a running job | A global bus pipe, appended to the existing pipes reflectively | Dispatcher::$pipes staying where it is |
Apart from the route ordering, none of these dependencies is guaranteed. The approach holds up because each assumption is isolated to one feature, degrades to that feature missing plus a log line saying so, and is checked by CI against the upstream development branch.
Using it with Skyline#
Because Skyline keeps the Laravel\Horizon\ namespace and declares replace: laravel/horizon,
the PHP-side hooks
knobik/laravel-horizon-job-output
uses (the repository whitelist, the view namespace, the bus pipe, the catch-all ordering) resolve against Skyline
exactly as they do against Horizon. The markup anchors depend on the dashboard build they
patch, and we have not run the package against Skyline's own assets, so we can't say more about those yet. If you try
it, we would like to hear how it goes.
The source is around 1,500 lines of PHP and JavaScript, and close to half of it is comments explaining why the code is written the way it is.
For the queue side, the life of a Laravel queued job traces the same Redis structures this package reads from, including the reserved sorted set behind its Reserved Jobs page, and timeout vs retry_after explains why a reservation whose worker died is a state worth having a screen for. Skyline vs Horizon covers what we changed in the dashboard itself.
Frequently asked questions
How do you add a panel to the Laravel Horizon dashboard without forking it?
Override the horizon::layout view, but keep Horizon's own view path reachable under a second namespace first, with addNamespace('horizon-original', ...) followed by prependNamespace('horizon', ...). The override then renders Horizon's real layout and patches string anchors in the resulting HTML, rather than shipping a copy of the layout that goes stale on the next Horizon release. Mount points are spliced in after the router view so they land inside Vue's in-DOM template as static nodes.
Can you add a field to Horizon's job API without writing a controller?
Yes. RedisJobRepository reads each job hash with HMGET against a fixed whitelist held in its public $keys property, rather than with HGETALL. Appending a field name to that array at boot makes the field flow through Horizon's existing /api/jobs/{id} endpoints with no route override, no second request and no separate authorization to get wrong. The repository is a singleton, so the change applies process-wide.
How can a package add a new page to Horizon's compiled Vue router?
It does not have to touch the router. Horizon's dashboard ends in a catch-all GET route that serves the layout for any path under its prefix, so a new URL already renders the app. The compiled router matches nothing on that path and renders an empty router view, which leaves a server-injected mount point as the only content in the column. The sidebar link must be a plain anchor rather than a router-link, because a router-link pointing at a route the bundle does not know about resolves to nothing.
Why register package routes in register() instead of boot()?
Because whichever route is registered first wins, and Horizon adds its catch-all from its own boot(). Every service provider's register() runs before any provider's boot(), so registering there beats the catch-all regardless of the order packages were discovered in. The trade-off is that Horizon has not booted yet, so its route group and middleware stack have to be rebuilt rather than reused.
How do you attach an output object to a queued job that is already running?
With a global bus pipe. A queued job is serialized, and an unserialized object never runs its constructor, so anything attached at dispatch time is gone by the time the worker has the job. A pipe runs inside the worker with the real command instance and $command->job already set. Append it to the dispatcher's existing pipes so other packages' pipes keep working. The existing list has to be read with reflection, because pipeThrough() replaces it and there is no getter.
Keep reading
14 min read
Laravel Horizon Alerting: What Horizon Sends on Its Own and What Prometheus Adds
Horizon alerting layer by layer: LongWaitDetected, alert rules for spatie/laravel-prometheus and a counter...
9 min read
Full-Page Caching in Laravel: Large Redis Entries, predis vs phpredis, and Compression
Full-page cache entries in Redis can reach megabytes. Compare predis and phpredis, enable phpredis zstd...