knobik / laravel-horizon-job-output
Job Output for Laravel Horizon — give queued jobs the output API an Artisan command has, and watch it live on the job details page.
Package info
github.com/knobik/laravel-horizon-job-output
pkg:composer/knobik/laravel-horizon-job-output
Requires
- php: ^8.2
- illuminate/contracts: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- laravel/horizon: ^5.0
Requires (Dev)
- laravel/pint: ^1.0
- orchestra/testbench: ^10.0|^11.0
- phpunit/phpunit: ^11.0|^12.0
README
Give a queued job the same output API an Artisan command has, and watch it live on the Horizon job details page.
use Knobik\HorizonJobOutput\Concerns\WritesJobOutput; class RebuildSearchIndex implements ShouldQueue { use Queueable; use WritesJobOutput; public function handle(): void { $this->info('Rebuilding search index'); $this->comment('scanning 20 shards'); $this->withProgressBar($shards, fn ($shard) => $shard->rebuild()); $this->info('Index rebuilt'); } }
info(), line(), comment(), error(), table(), newLine() and
withProgressBar() all work exactly as they do in a command — the trait uses
Laravel's own InteractsWithIO.
The interactive prompts (ask(), confirm(), choice(), …) throw instead. A
queue worker has no input stream, so they would otherwise block until the job
timed out.
Queued Artisan commands
A command queued with Artisan::queue() is a job like any other, and its output
lands on the dashboard the same way — no trait, nothing to change in the command:
Artisan::queue('search:reindex', ['--fresh' => true]);
The same goes for a command a job runs itself, so a job that leans on an existing command still shows everything that happened:
public function handle(): void { $this->info('Reindexing'); Artisan::call('search:reindex'); // the command's output lands here too }
Artisan::output() keeps returning what the command wrote, so a job that reads
it back is unaffected, and a command handed a buffer of its own still writes
only there.
Set capture_artisan to false to leave commands out of it. Worth doing if
your jobs call commands that say a great deal — a command's output shares the
job's max_bytes budget, so a talkative one can crowd out what the job wrote.
Scheduled commands are a different thing and are not covered: $schedule ->command() runs php artisan as its own process rather than queueing a job,
so Horizon never sees it. $schedule->job() queues normally and works.
Reserved Jobs page
The package also adds a Reserved Jobs page to the dashboard sidebar, listing the jobs the workers are holding right now — something Horizon has no view of. Reserved jobs are otherwise mixed into the pending list with nothing to set them apart.
It reads the queue's own reserved set rather than Horizon's pending list, so its cost tracks the number of workers rather than the size of the backlog. That set also carries each reservation's deadline, so a job whose worker died is listed with a Reservation expired badge until Horizon releases it back onto the queue — the one case you would open this page to diagnose, and one nothing else surfaces.
Rows link through to the job details page, and any job with live output is flagged so you know there is something to look at.
Set reserved_page to false to leave Horizon's navigation untouched.
Releasing a reservation
Each row has a Release button that puts the job straight back onto its queue. Nothing is discarded — this is the same move Laravel makes for a reservation that has run out, brought forward.
It is worth having because Laravel only makes that move inside RedisQueue::pop().
A queue whose workers have all died is never popped, so its abandoned jobs stay
reserved indefinitely: Reservation expired on this page, and nowhere at all in
Horizon. Releasing one hands it to the next worker that comes back.
The button is on every row, not only the expired ones, because a wedged worker can hold a reservation that keeps looking healthy. Releasing a reservation that has not expired is a different proposition, though, and the dialog says so before it does anything: Redis has no way to tell the worker to stop, so the job is queued a second time and both copies run to completion. Releasing one that has expired carries no such risk — nothing is running it.
A release only takes effect if the job is still reserved at that moment. If the worker finished it in the second between the page rendering and the click, the dashboard reports that there was nothing to release rather than resurrecting a job that has already run.
One thing to expect: the job goes back onto the queue exactly as it was
reserved, attempt count and all, so the worker that picks it up counts one more
attempt as it always does. A job already on its last try therefore comes back
only to be marked failed. That is not something this button does differently —
it is what happens to any reservation Laravel recovers — but the click is what
triggers it, so the dialog says so too. Give a job $tries room if you expect to
release it.
Set release_reservations to false to remove the button and the endpoint
behind it, keeping the page itself.
Installation
composer require knobik/laravel-horizon-job-output
That is the whole setup. The service provider is auto-discovered, and the panel adds itself to Horizon's dashboard.
How it works
Output is stored as a field on Horizon's own job hash in Redis, rather than
under a key of its own. That means it shares one key and one TTL with the job, so
it is trimmed by Horizon's existing horizon.trim.* settings with no cleanup
code, no scheduled command, and no way for the two to fall out of sync.
Four integration points, none of which require changes to Horizon:
RedisJobRepository::$keysis a public whitelist read withHMGET. The provider appendsoutputto it, so the field flows through the existing/api/jobs/{id}endpoints with no controller or route overrides.- A global bus pipe attaches the output instance while the job runs. Unserialized jobs never run their constructor, so this cannot be done at dispatch time.
- The console kernel is decorated for the length of a job, so that an Artisan command run during it writes into the job's output instead of being discarded. The kernel is put back however the job ends.
- The
horizon::layoutview is overridden to inject the panel. Rather than shipping a copy that drifts, the override renders Horizon's real layout and patches a few anchors in the result — which is also how the Reserved Jobs page adds its sidebar link, since Horizon's router is compiled into its bundle.
Every one of those patches is optional. If Horizon changes the markup underneath them the package logs a warning and leaves that piece out; the dashboard still renders and jobs still run.
Configuration
php artisan vendor:publish --tag=horizon-job-output-config
| Option | Default | Purpose |
|---|---|---|
enabled |
true |
Turn capture and the dashboard panel off entirely |
reserved_page |
true |
Show the Reserved Jobs page and its sidebar link |
release_reservations |
true |
Allow a reserved job to be put back onto its queue from that page |
max_bytes |
65536 |
Truncate a runaway job's output |
flush_interval_ms |
500 |
How often a running job writes to Redis |
poll_interval_ms |
2000 |
How often the dashboard polls while a job runs |
ansi |
true |
Store style tags as colour, rather than plain text |
verbosity |
normal |
quiet, normal, v, vv or vvv — see below |
capture_artisan |
true |
Record the output of Artisan commands a job runs, and of queued ones |
renderer |
terminal |
terminal or html — see below |
columns |
80 |
Terminal width; match what the job wrote at |
Setting enabled to false stops output being recorded and removes the panel,
but jobs using the trait keep working — their $this->info() calls simply go
nowhere. Disabling the package never changes whether your jobs run.
Verbosity
Jobs write at the same levels a command does, named after Artisan's flags. The level decides two things.
The write helpers take an optional verbosity, and anything above the current
level is discarded — so $this->info('shard 3 of 20', 'vv') says nothing at the
default and appears once the level is raised:
$this->info('Rebuilding search index'); // always $this->line('scanning shard 3', null, 'vv'); // only at vv or above
And a progress bar reports more, exactly as it does under php artisan -vv,
because Symfony picks the bar's format from the output's verbosity:
normal 12/20 [████████████████░░░░░░░░] 60%
v 12/20 [████████████████░░░░░░░░] 60% 4 secs
vv 12/20 [████████████████░░░░░░░░] 60% 4 secs/7 secs
vvv 12/20 [████████████████░░░░░░░░] 60% 4 secs/7 secs 24.0 MiB
A single job can override the setting, which is usually the better place for it — the long job whose progress is worth timing is rarely every job in the application:
public function outputVerbosity(): ?string { return 'vv'; }
quiet suppresses everything, progress bars included, so a job at that level
records nothing at all rather than "text but no bars".
Renderers
terminal inlines a vendored xterm.js build and renders
output through a real terminal emulator, so a progress bar redraws in place
exactly as it would in a shell. It adds roughly 345KB to each dashboard page.
html renders the output as styled HTML with no extra payload. Sequences that
rewrite the current line are collapsed, so a progress bar shows only its final
state. This is also the automatic fallback if the vendored build is unavailable.
Running jobs outside Horizon
Writing output is never a reason for a job to fail. Whatever path a job takes, the write helpers work:
| How the job runs | Output |
|---|---|
| Queued, processed by Horizon | captured and shown on the dashboard |
Queued, processed by queue:work |
captured — Horizon records the job when it is pushed, not when it runs |
dispatchSync() / dispatch_sync() |
discarded; there is no Horizon record to attach it to |
(new Job)->handle() directly |
discarded |
| Package disabled | discarded |
To assert on output in a test, attach one and read it back:
$job = new RebuildSearchIndex(); $job->setOutput(new OutputStyle(new ArrayInput([]), $buffer = new BufferedOutput())); $job->handle(); $this->assertStringContainsString('Index rebuilt', $buffer->fetch());
The interactive prompts are the one deliberate exception: ask(), confirm()
and friends throw rather than returning something meaningless, because a worker
has no input stream and they would otherwise block until the job timed out.
Requirements
- PHP 8.2+
- Laravel 12 or 13
- Laravel Horizon 5
License
MIT — see LICENSE.md.
