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.

Maintainers

Package info

github.com/knobik/laravel-horizon-job-output

pkg:composer/knobik/laravel-horizon-job-output

Transparency log

Statistics

Installs: 10

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

v1.3.0 2026-08-03 09:32 UTC

This package is auto-updated.

Last update: 2026-08-03 09:34:35 UTC


README

Give a queued job the same output API an Artisan command has, and watch it live on the Horizon job details page.

The output panel on a Horizon job details page, showing a job's log lines and a progress bar

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.

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::$keys is a public whitelist read with HMGET. The provider appends output to 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::layout view 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
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.