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, no slots and no hooks. If you want to add a panel to the job details page, the obvious routes are to fork the package or to publish its assets and patch the bundle — and both leave you re-doing the work on every release. There is a third way, and knobik/laravel-horizon-job-output is a working demonstration of it.
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. It does all of
that without forking Horizon, without publishing its assets, and without a single line of Horizon's own code being
modified.
This article is a walk through how, because the techniques generalise: every one of them applies to any package that wants to extend a Laravel package whose frontend is shipped pre-compiled.
Every technique below is knobik/laravel-horizon-job-output, by knobik, MIT licensed. The code quoted in this article is theirs, lightly abridged for length. We are writing it up because it is an unusually clean solution to a problem plenty of people have and most people solve by forking. If any of it is useful to you, star the repo — that is where the work actually happened.
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 is a free SPA route. 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 — leaving 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 essentially one <div id="horizon"> containing a <router-view>, a sidebar, and a
script tag. There is no Horizon::registerPanel(), no view slot, no JavaScript event bus. The extension
surface is exactly zero.
So the choices normally look like this:
| 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 silently overwrites it on the next deploy. |
| 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 takes none of them. Its position is that Horizon's rendered HTML is a perfectly good extension point as long as you treat every assumption about it as provisional. That single decision — patch the render, not the source — is what makes the rest of the design hang together.
Getting your data into Horizon's API#
Before anything can be shown, the output has to reach the browser. The tempting move is a new endpoint:
GET /horizon/api/job-output/{id}, your own controller, your own Redis read. The package does something
quieter.
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;
}
}
That is the whole integration. The repository is a singleton, so appending output to it once at boot applies
process-wide, and from that point Horizon's own /api/jobs/{id} endpoint returns the field alongside
status, payload and the rest. No route, no controller, no second request from the dashboard,
and no authorization to get wrong — the data rides the endpoint Horizon already gates.
The storage side is chosen with the same instinct. 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 it is trimmed by the same policy —
no cleanup command, no scheduled task, and no way for the two lifetimes to fall out of sync. The
exists() guard is the detail that makes it safe: HSET on a missing key would create a fresh
hash with no expiry at all, which is a leak that grows one job at a time forever.
Attaching to an existing key inherits its retention policy for free. Creating your own means writing one.
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 much
is a known trick, and it is normally where the trouble starts: you now have to supply a layout, which means
copying Horizon's and re-syncing it forever.
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 itself is then ten lines, and none of them are Horizon's markup:
{!! app(\Knobik\HorizonJobOutput\LayoutDecorator::class)->decorate(
view('horizon-original::layout', ['isDownForMaintenance' => $isDownForMaintenance])->render()
) !!}
Horizon renders its real layout; 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 — only with a few extra nodes in it.
Two details worth stealing. The first is that this runs from $this->app->booted() and through
callAfterResolving('view', …), so a request that never renders a view never pays to construct Blade's
finder. The second is that every splice goes through one method that treats a missing anchor as a non-event:
// 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 — "the output panel will not be shown", "the Reserved Jobs link
will be missing", "nothing this package adds will load". The failure mode of a Horizon release moving an anchor is a log
line naming the exact feature that disappeared, not a 500 on the dashboard and not a silently blank panel.
Where to put a mount point in a Vue app you don't control#
This is the part that most attempts get wrong. The instinct 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, a route change, a re-render — removes it, or worse, leaves Vue's diff comparing
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 lands 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 subsequent
patch. The mount is inside the app, positioned exactly where you want it in the layout, and simultaneously invisible to
the diff. Plain JavaScript can own it outright.
The scripts and styles go the other way — just before </body>, which puts them
outside #horizon so Vue never tries to compile them at all.
If you must add DOM to a compiled SPA you don't control, add it to the template the app compiles from, before the app boots — never to the DOM the app has already rendered. One is a static node the framework agrees to leave alone; the other is a race you lose on the next re-render.
Adding a whole page the compiled router has never heard of#
A panel on an existing screen is one thing. The package also adds a new page — Reserved Jobs, at
/horizon/reserved — to a router that was compiled months ago and has no route for it.
It works because of a property of Horizon's routing that is easy to overlook: the dashboard ends in a catch-all GET
route matching everything under its prefix, all of which returns the same layout. So /horizon/reserved is
already a valid URL that already serves the app. The compiled Vue router then finds no route matching that path and
renders an empty <router-view> — which leaves the package's own mount, sitting immediately after it,
as the only content in the column. The page renders itself into a hole the router politely leaves open.
The sidebar link needs one deliberate deviation from Horizon's own markup:
<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>
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 at all. A real href performs a real navigation, and Vue leaves it alone. The href itself is built
from horizon.proxy_path and horizon.path, mirroring how Horizon's own bundle computes its base
path, so the link survives a custom dashboard path and 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 a catch-all that matches everything. Route order decides who wins, and route order is registration order.
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();
}
That comment is the whole lesson: package discovery order is not something you control, but the
register()-before-boot() ordering of the container is guaranteed. Registering in
register() wins the race unconditionally.
It has a consequence, though — Horizon has not booted yet, so its route group cannot be reused and has to be rebuilt. That means rebuilding the middleware stack too, and the package is careful about what belongs in it:
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 collects 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 go
looking for a class by that name. Rebuilding the contents is the version-tolerant option.
The authorization detail is sharper still. Horizon hangs its Authenticate middleware on its
base controller, not on the route group. A controller that does not extend that base class therefore
inherits no authorization whatsoever — so the package applies it 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 take one thing from this section into your own Horizon package: check where the package you are extending
puts its gate. A route group under horizon.middleware alone gets you the web stack and
nothing else — your endpoint would be open to anyone who can reach the dashboard's URL, whether or not they can pass
the viewHorizon gate.
One last routing subtlety: the feature toggles are enforced in the controllers, not around the 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, and flipping the config would then need a
route:clear to take effect. That is the kind of bug that costs someone an afternoon, and it is avoided by
moving one if a few lines down the stack.
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 — the router is inside a bundle it has no handle on. 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();
}
}
popstate covers the back and forward buttons, which the browser fires on its own; the wrapper covers
forward navigation, which it does not. This lives in a shared support module that is concatenated ahead of the feature
scripts, so the history methods are wrapped exactly once no matter how many features are enabled — rather than as a side
effect of whichever script happened to load first.
From there, the current screen is read straight off the URL, since every Horizon screen is a real 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;
}
And because the mount point is created by Vue when it compiles the layout template, a cold load may reach this code
before the element exists — so there is a small bounded retry (whenElementExists, 50 attempts at 100ms)
rather than an assumption about 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/ is possible but fragile — it needs a publish step in every deploy, and Horizon's own
horizon:publish is right next door overwriting things.
So everything is inlined into the layout at render time: one <style>, one
<script type="module">, plus a settings object serialised with Js::from() — the same
helper Horizon uses for its own settings, which applies the escaping needed to embed data inside a script tag safely.
The most interesting piece is the terminal renderer. The package vendors an xterm.js build so a progress bar redraws in place on the dashboard exactly as it would in a shell. An ESM build ends in an export statement, and an export is inert inside an inline module — nothing can import it. So the export is rewritten into a global assignment on the way out:
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 exactly as brittle as it sounds, and the package treats it that way: 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 at all. A brittle optimisation with a solid fallback is a different proposition from a brittle requirement.
The other half: capturing output from a job that is already running#
The dashboard side is only useful if there is something to show, and getting an output object into a running job has its own set of obstacles worth recording.
You cannot do it 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 gives you:
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 existing pipes are read
reflectively and preserved. Appending rather than replacing is the difference between coexisting with every other
package that uses bus pipes and quietly breaking them.
Two smaller pieces round it out, both of which come from the same instinct of asking "what is the framework's real behaviour here?" rather than assuming:
-
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()and keeps the job the worker has in hand in a small singleton, then only hands out its id when the payload'scommandNamematches the command being piped. That last check is what stops a command dispatched inside another job from writing over the outer job's output.
And the write path itself is buffered with a flush interval, capped at max_bytes, and flushed with
force: true from a finally — so a job that throws keeps whatever it managed to write before it
blew up, which is precisely the output you wanted 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. A package built this way is one release away from breaking, and the honest response is not to pretend otherwise but to make the breakage cheap and visible.
The package does three things about it. 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 there is a scheduled CI job:
# .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 exactly which four internals might have moved. Upstream drift becomes a Monday morning notification
instead of a user's bug report after a release.
If your package depends on another package's internals, the test that matters most is the one you run against its unreleased branch.
The pattern, generalised#
Strip out the specifics and there is a reusable playbook here 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 — this one is guaranteed by the container |
| 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 |
Each row has a real dependency and none of them are guaranteed. What makes the approach defensible is not that the assumptions are safe — it is that each one is isolated, each one degrades to "that feature is missing, here is a log line saying so", and each one is watched by CI against the upstream development branch.
The core takeaway#
"This package has no extension point" is usually treated as the end of the conversation, and the answer is usually a fork. It doesn't have to be. A rendered HTML string is an extension point. A public whitelist property is an extension point. A catch-all route is an extension point. A framework's own guarantee that static nodes are never patched is an extension point. None of them are documented as such, and all of them work — provided you build on them the way knobik/laravel-horizon-job-output does: one assumption per feature, every assumption isolated, every failure logged with its consequence, and a canary watching upstream.
Because Skyline keeps the Laravel\Horizon\ namespace and declares replace: laravel/horizon,
the PHP-side hooks in this article — 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 are the piece that depends on the
dashboard build in front of them, and we have not run the package against Skyline's own assets to say more than that.
If you try it, we would like to hear how it goes.
Go and read the source. It is around 1,500 lines of PHP and JavaScript and close to half of it is comments explaining why — which is a rarer and more useful thing to read than most packages twice its size.
If queue internals are your thing, 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 — read reflectively, since pipeThrough() replaces the list and there is no getter — so other packages' pipes keep working.
Keep reading
24 min read
The Life of a Laravel Queued Job: Every State, Every Transition
How a Laravel queued job moves through Redis: dispatch, reserve, retry, fail. The full lifecycle, traced...
13 min read
Laravel Job timeout vs retry_after: The Ordering Rule Nothing Enforces
Why a Laravel job runs twice: the timeout and retry_after ordering rule, the real defaults, and the two very...