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.
Credit where it is due
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.
The general rule
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.
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.
Top comments (0)