nsumbadze / horizonflow
An independently maintained Laravel Horizon fork with live queue-flow visualization and operational controls.
Requires
- php: ^8.0
- ext-json: *
- ext-pcntl: *
- ext-posix: *
- illuminate/contracts: ^9.21|^10.0|^11.0|^12.0|^13.0
- illuminate/queue: ^9.21|^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^9.21|^10.0|^11.0|^12.0|^13.0
- laravel/sentinel: ^1.0
- nesbot/carbon: ^2.17|^3.0
- ramsey/uuid: ^4.0
- symfony/console: ^6.0|^7.0|^8.0
- symfony/error-handler: ^6.0|^7.0|^8.0
- symfony/polyfill-php83: ^1.28
- symfony/process: ^6.0|^7.0|^8.0
Requires (Dev)
- mockery/mockery: ^1.0
- orchestra/testbench: ^7.56|^8.37|^9.16|^10.9|^11.0
- phpstan/phpstan: ^1.10|^2.0
- predis/predis: ^1.1|^2.0|^3.0
Suggests
- ext-redis: Required to use the Redis PHP driver.
- predis/predis: Required when not using the Redis PHP driver (^1.1|^2.0|^3.0).
Provides
None
Conflicts
None
Replaces
- laravel/horizon: ^5.0
This package is auto-updated.
Last update: 2026-08-26 08:02:45 UTC
README
Live queue-flow visibility and operational insights for Laravel Horizon.
HorizonFlow is an independently maintained fork of Laravel Horizon. It retains Horizon's dashboard and code-driven worker configuration while adding a live operational workspace for understanding how jobs move through queues. HorizonFlow is not an official Laravel product.
Installation
HorizonFlow is installed instead of laravel/horizon; the two packages must not be installed together.
For a new installation, require HorizonFlow and publish Horizon's application service provider and configuration:
composer require nsumbadze/horizonflow php artisan horizon:install
Laravel package discovery registers Laravel\Horizon\HorizonServiceProvider. The install command publishes config/horizon.php and creates app/Providers/HorizonServiceProvider.php, where dashboard authorization is configured. HorizonFlow's additional settings have working defaults; publish them only when you need to customize Live Flow:
php artisan vendor:publish --tag=horizonxflow-config
Run Horizon as you would the upstream package:
php artisan horizon
Replacing Laravel Horizon
Applications already using laravel/horizon should preserve their config/horizon.php and app/Providers/HorizonServiceProvider.php, remove the upstream package requirement, and then install HorizonFlow with dependency updates allowed:
composer remove laravel/horizon --no-update composer require nsumbadze/horizonflow --with-all-dependencies
The fork intentionally retains the Laravel\Horizon PHP namespace, service providers, Artisan commands, configuration shape, dashboard routes, and Redis data conventions. Composer declares that HorizonFlow replaces the Laravel Horizon 5.x line, preventing both implementations from being installed together. Review UPGRADE.md and test the change in a non-production environment before deployment; HorizonFlow has its own releases and version numbers and does not claim the same versions as upstream Horizon.
Compatibility
HorizonFlow requires PHP 8.0 or later, Laravel 9.21 through 13, the JSON, PCNTL, and POSIX PHP extensions, and a Redis connection supported by Laravel. Install either the PhpRedis extension or predis/predis. PCNTL and POSIX are not available on Windows, so HorizonFlow should run in a Linux environment or a compatible container/virtual machine.
Live Flow
Live Flow is available at /horizon/live-flow. It visualises producers, queues, jobs, workers, and results in real time across Redis and database queue drivers.
Under a row of queue KPIs, the workspace is organised into four areas:
- Flow — the queue topology as an SVG graph (producers → queues → workers → completed/failed, with per-edge throughput), or the same data as a filterable, sortable queue table. Selecting a node opens the Inspector, which shows that node's metrics, drain ETA, failure rate, recent job classes, and a suggested action when a queue is under backpressure.
- Activity — a rolling stream of jobs entering, completing, and failing.
- Insights — an incident timeline (long waits, job failures, supervisor deployments), monitored tags, and recent batches.
- Horizon controls — pause and continue the master supervisors or an individual supervisor.
Selecting a Redis queue in the Inspector also exposes queue pause/resume controls and safe cancellation actions for its pending or running jobs. The toolbar and the Inspector can also dispatch a job onto a queue; see Dispatching Jobs.
The active workspace, graph/table mode, time window, queue filter, and selected node are reflected in the query string, so operational views can be shared directly.
To explore without a live queue, run composer serve:demo. This boots the workbench application with generated demo data and seeds a handful of failed jobs you can open and retry.
Configuration
Live-flow behaviour is configured via config/horizonxflow.php:
| Key | Default | Description |
|---|---|---|
flow.source |
redis |
One of redis, database, auto, mock. auto merges every configured source into a single payload. |
flow.sources |
[redis] |
Source list when flow.source = auto. Set via HORIZONXFLOW_FLOW_SOURCES (comma-separated). |
flow.recent_jobs.max |
50 |
Cap on per-queue job rows returned to the inspector. |
flow.cache.queue_keys_ttl |
10 |
Seconds the Redis SCAN for queue keys is cached for. Set to 0 to disable. |
flow.cache.payload_ttl |
1 |
Seconds the full repository payload is memoised across requests. |
flow.database.connections |
[] |
Explicit list of connections for the database driver. Empty means auto-discover from queue.connections. |
flow.database.discover_connections |
false |
When true the database driver also walks database.connections (driver: mysql/pgsql/sqlite/sqlsrv) to find candidate jobs tables. |
flow.database.failed_table |
failed_jobs |
Table that holds failed-jobs entries. |
dispatch.enabled |
true |
Whether jobs may be dispatched from the dashboard at all. |
dispatch.discover |
true |
Whether dispatch.paths are walked for job classes. |
dispatch.paths |
[] |
Directories searched for job classes. Empty means app/Jobs. |
dispatch.allowed |
[] |
Class names or Str::is patterns an operator may dispatch. Empty means every discovered job. |
dispatch.denied |
[] |
Class names or patterns that may never be dispatched. Takes precedence over dispatch.allowed. |
dispatch.max_delay |
86400 |
Largest delay, in seconds, an operator may ask for. |
cancellation.run_ttl |
3600 |
Seconds a cancelled run keeps blocking its jobs before lifting on its own. |
cancellation.purge_limit |
5000 |
Most pending jobs walked when purging a cancelled run. |
cancellation.on_lookup_failure |
defer |
What a worker does when it cannot read whether a job's run was cancelled: defer holds it back, run lets it through. |
Routes
| Path | Returns |
|---|---|
GET /horizon/api/flow |
Full live-flow payload (kept for back-compat). |
GET /horizon/api/flow/summary |
Header KPIs plus failed_in_window, window_seconds, and a health[] block per source. |
GET /horizon/api/flow/graph |
Nodes and edges for the SVG flow graph. |
GET /horizon/api/flow/queues |
Filterable / sortable queue rows (rows omit per-row job arrays for cheapness). |
GET /horizon/api/flow/queue-jobs |
Recent jobs + job-classes for a single queue (?key=driver:connection:name). |
GET /horizon/api/flow/events |
Activity stream. Pass ?since=<unix ts> for incremental polling. |
GET /horizon/api/flow/incidents |
Recent incidents (long waits, job failures, supervisor deployments) for the Insights timeline. |
POST /horizon/api/flow/queues/pause |
Pause one Redis queue while retaining pending and newly dispatched jobs. |
POST /horizon/api/flow/queues/resume |
Resume processing one paused Redis queue. |
POST /horizon/api/jobs/{id}/cancel |
Cancel a pending job or request cooperative cancellation of a running job. |
GET /horizon/api/jobs/dispatchable |
The job classes an operator may dispatch, plus the configured queue connections. |
GET /horizon/api/jobs/dispatchable/parameters |
The constructor parameters of one dispatchable job class (?class=App\Jobs\Example). |
POST /horizon/api/jobs/dispatch |
Dispatch a job with the given constructor arguments and queue options. |
GET /horizon/api/flow/runs |
The runs that are currently cancelled, with their counters and expiry. |
POST /horizon/api/flow/runs/cancel |
Cancel a whole run, by group or by a job that belongs to it. |
POST /horizon/api/flow/runs/release |
Lift a run cancellation. |
GET /horizon/api/jobs/{id}/run |
The run a job belongs to, and whether that run is cancelled. |
Abilities
viewHorizon— required to enter the dashboard (existing Horizon gate).controlHorizon— required for mutation endpoints (POST /jobs/retry/{id},POST /jobs/{id}/cancel,POST /jobs/dispatch,POST /flow/queues/{action},POST /flow/runs/{action},POST /masters/{action},POST /supervisors/{name}/{action}) and for the reads that back them:GET /jobs/failed/{id}/parameters,GET /jobs/dispatchable,GET /jobs/dispatchable/parameters,GET /jobs/{id}/run, andGET /flow/runs. When the gate is undefined, mutations are only allowed inlocalandtestingenvironments; everywhere else, define the gate inHorizonApplicationServiceProvider::gate()to enable destructive actions for a trusted subset of users.
Dispatching a job and cancelling a run do not accept the environment fallback. They run or stop application code, so controlHorizon must be defined and must pass, whatever APP_ENV says — an application deployed with APP_ENV=local would otherwise hand both to anyone who can reach the dashboard. With the gate undefined those routes answer 403 and name the gate in the response; the dashboard hides the controls rather than offering something that cannot work. Set dispatch.enabled to false to remove dispatch entirely.
| Ability | Covers |
|---|---|
viewHorizon |
Entering the dashboard. |
controlHorizon (environment fallback applies) |
Retry, cancel one job, pause/resume queues and supervisors. |
controlHorizon (required, no fallback) |
POST /jobs/dispatch, POST /flow/runs/{action}, and the reads that back them. |
Environment Variables
HORIZONXFLOW_FLOW_SOURCE— overridesflow.source.HORIZONXFLOW_FLOW_SOURCES— comma-separated source list whenflow.source = auto.HORIZONXFLOW_FLOW_RECENT_JOBS_MAX— overridesflow.recent_jobs.max.HORIZONXFLOW_FLOW_QUEUE_KEYS_TTL— overridesflow.cache.queue_keys_ttl.HORIZONXFLOW_FLOW_PAYLOAD_TTL— overridesflow.cache.payload_ttl.HORIZONXFLOW_DISCOVER_DATABASE_QUEUES— overridesflow.database.discover_connections.QUEUE_FAILED_TABLE— overridesflow.database.failed_table.HORIZONXFLOW_DISPATCH_ENABLED— overridesdispatch.enabled.HORIZONXFLOW_DISPATCH_DISCOVER— overridesdispatch.discover.HORIZONXFLOW_DISPATCH_MAX_DELAY— overridesdispatch.max_delay.HORIZONXFLOW_CANCELLED_RUN_TTL— overridescancellation.run_ttl.HORIZONXFLOW_CANCELLED_RUN_PURGE_LIMIT— overridescancellation.purge_limit.HORIZONXFLOW_CANCELLED_RUN_ON_LOOKUP_FAILURE— overridescancellation.on_lookup_failure.
Job and Queue Controls
The Live Flow Inspector can pause an individual Redis queue and cancel one pending or running job. These controls deliberately preserve Laravel's queue safety boundaries:
- Pausing a queue does not reject dispatches or kill workers. A job already running finishes, while pending and newly dispatched jobs remain queued until the queue is resumed.
- Cancelling a pending job atomically removes its exact payload from the ready list or delayed set. If a worker reserves it first, HorizonFlow records a cooperative cancellation request instead of reporting a false success.
- A running worker process is never force-killed. Side effects already performed by a job cannot be rolled back by HorizonFlow.
- Cancelled jobs remain visible in the Inspector with their cancellation time and operator identifier. Repeated requests are idempotent, and completed or failed jobs return
409 Conflict.
Mutation routes use the controlHorizon ability described above. Queue connection and queue names are validated server-side, raw job payloads are never accepted from or returned to the control UI, and every destructive action has an explicit confirmation step.
Cooperative cancellation checkpoints
A running job must opt in before it can stop between units of work. Add InteractsWithCancellation and return from handle() when a checkpoint acknowledges the request:
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\InteractsWithQueue; use Laravel\Horizon\Concerns\InteractsWithCancellation; class SendCampaignMail implements ShouldQueue { use InteractsWithQueue; use InteractsWithCancellation; public function handle(): void { foreach ($this->recipients as $recipient) { if ($this->cancelIfRequested()) { return; } $this->sendTo($recipient); } } }
Place checkpoints before idempotent units of work. A cancellation requested while a single non-interruptible call is executing—for example, an SMTP hand-off—takes effect only after that call returns and the next checkpoint is reached.
Cancelling a whole run
Cancelling one job stops one job. That is rarely what you want when a job chains its own successor or fans work out: kill page 6 and the walk carries on from page 7. HorizonFlow can stop the whole run instead.
A run is whatever a job says it is. Jobs that belong together return the same key:
use Laravel\Horizon\Concerns\InteractsWithCancellation; final class FetchCitrusPageJob implements ShouldQueue { use InteractsWithCancellation; public function __construct( public readonly int $companyId, public readonly int $page = 1, ) { } public function cancellationGroup(): ?string { return "citrus-sync:{$this->companyId}"; } public function handle(): void { foreach ($this->pageOfProducts() as $product) { if ($this->cancelIfRequested()) { return; } $this->process($product); } self::dispatch($this->companyId, $this->page + 1); } }
Give the jobs it fans out to the same key and one cancellation covers all of them.
Open any job in Live Flow and it now shows the run it belongs to, with cancel run next to the usual per-job actions. Cancelling has three effects, and they do not all land at once:
- Pending jobs are purged now. Matching payloads are removed from the ready list and delayed set and retained as cancelled, exactly as a single cancellation would.
- Queued jobs are dropped at pickup. A worker refuses a job whose run is cancelled before
handle()runs. This is what ends a self-chained walk: the job in flight may still queue its successor, but that successor never starts. - A running job stops at its next checkpoint. Only if it calls
cancelIfRequested(). Without checkpoints, the job in flight finishes its work; the run still stops at the next link.
A cancellation is a standing block, not a one-off sweep, so a run cannot re-seed itself while it stands. It lifts on its own after cancellation.run_ttl, and the Inspector lists every active cancellation with how many jobs were purged, how many have been dropped since, and how long is left — with lift to end it early.
Groups may contain letters, numbers, dashes, underscores, dots, and colons, and are rejected otherwise so a group can never address unrelated Redis keys. Purging walks at most cancellation.purge_limit pending jobs; when it hits that limit the response says so rather than reporting a clean sweep.
Reading a job's run means unserializing its command, so workers do no payload work unless some run is actually cancelled — and even then, only payloads whose job class declares cancellationGroup() are unserialized at all.
The two registry lookups a worker makes fail in deliberately different directions:
- Is any run cancelled? Every job in the application passes this one, so an unreadable registry lets jobs run as they would without the feature. Failing closed here would turn a Horizon metadata blip into a queue-wide outage.
- Is this run cancelled? Only reached for a job that belongs to a run while some run is already cancelled, having just read the registry successfully. Failing there is a genuine anomaly, so the job is held back for a later attempt instead of run. An operator asked for this work to stop, and a deferred job is recoverable in a way that work already performed is not. Set
cancellation.on_lookup_failuretorunto prefer availability instead.
Neither failure is swallowed — both are reported through the application's exception handler, so a persistent blind spot is visible rather than silent.
Queue and job controls currently support Redis queues. Database queues remain observable in Live Flow but do not expose these mutation controls.
When flow.source is mock, the Inspector exposes the same controls as a session-only visual simulation. Pausing, cancelling, and retrying update only the browser state and never call a mutation endpoint or change Redis. Mock failures are available from Live Flow's queue Inspector and Activity workspace; Horizon's separate Failed Jobs page continues to show only real failed jobs.
Retry With Parameters
A failed job usually fails because of what it was handed: a wrong path, a batch size that was too large, a flag left on. Horizon can only push that same job back onto the queue unchanged, so the normal fix is a tinker session or a one-off command. HorizonFlow lets you change the arguments and retry from the dashboard instead.
Open a failed job and press Edit Parameters. HorizonFlow reads the job class constructor and lists every parameter it accepts, prefilled with the values the failed job was queued with:
Change what you need and press Retry With Parameters. The job is queued as a normal retry, so it still shows up under the original job's retry history.
What you can edit:
string,int,float,bool,arrayanditerableparameters, plus untyped ones holding those values. Arrays are edited as JSON.- Nullable parameters get a Send as null toggle.
- Parameters that were never passed still appear, prefilled with their declared default.
What you cannot edit, and why the panel says so next to each one:
- Objects and Eloquent models. They are shown read-only rather than hidden, so you can still see what the job was carrying.
- Queued closures, and jobs whose class no longer exists in the application.
Values are cast to the parameter's declared type before the job is queued ("9" becomes 9 for an int). Anything that does not fit is rejected with a 422 and the reason, and nothing is queued. Jobs implementing ShouldBeEncrypted are decrypted for inspection and re-encrypted on the way out.
Editing parameters is gated by controlHorizon, the same ability an ordinary retry needs. Both the read and the retry go through it:
| Path | Returns |
|---|---|
GET /horizon/api/jobs/failed/{id}/parameters |
The job's constructor parameters, their current values, and whether each one may be edited. |
POST /horizon/api/jobs/retry/{id} |
Retries the job. Accepts an optional parameters object of overrides. |
To try it locally, composer serve:demo seeds three failed demo jobs. You can also seed or remove them directly:
php artisan horizonxflow:demo-jobs php artisan horizonxflow:demo-jobs --clear
Dispatching Jobs
Putting one job on a queue by hand normally means a tinker session or a one-off Artisan command. HorizonFlow can do it from Live Flow instead, with the same reflection that backs Retry With Parameters.
Press Dispatch job in the Live Flow toolbar, or dispatch to queue in the Inspector to arrive with a queue already chosen. Pick a class on the left, fill in its constructor arguments, and set where and when it runs:
- connection — any connection in
config/queue.php. Leaving it alone uses the job's own$connection, or the application default. - queue — free text, with the queues Live Flow has already seen offered as suggestions. Leaving it alone uses the job's own
$queue, or the connection default. - delay — in seconds, minutes, or hours, capped by
dispatch.max_delay.
The footer states the connection, queue, and delay the job will actually land with before you press Dispatch job.
Which jobs appear
Discovery walks app/Jobs for classes implementing ShouldQueue that can be constructed. Point it somewhere else with dispatch.paths, and turn it off entirely with dispatch.discover.
The listed set is the whole dispatchable surface. A class that is neither discovered nor named in dispatch.allowed is rejected with a 422, so a request can never reach an arbitrary queueable class inside the framework or a third-party package:
'dispatch' => [ 'allowed' => [ 'App\Jobs\*', 'Vendor\Package\Jobs\SyncCatalog', ], 'denied' => [ 'App\Jobs\Billing\*', ], ],
Both lists accept exact class names or Str::is patterns, and dispatch.denied always wins. An exact entry in dispatch.allowed is dispatchable even when discovery never saw it, which is how you expose a job that lives outside app/Jobs. Set dispatch.enabled to false to remove the controls and refuse the endpoints outright.
What you can pass
Constructor parameters follow the same rules as retrying with edited parameters: string, int, float, bool, array and iterable, plus untyped ones. Arrays are entered as JSON, nullable parameters get a send as null toggle, and optional parameters left blank fall back to their declared defaults.
A job that requires something HorizonFlow cannot build — an Eloquent model, a date object, any other class — is shown with the parameter that blocks it and cannot be dispatched. Values are cast to the declared type before the job is constructed, and anything that does not fit is rejected with a 422 and the reason, without queueing.
Dispatching is gated by controlHorizon, the same ability pausing a queue needs. When Live Flow is showing demo data (flow.source = mock), dispatching is simulated in the browser and no job reaches Redis.
To try it locally, composer serve points discovery at HorizonFlow's own demo jobs. AssembleSprocket and PingSatellite cover every editable parameter type, and FlashBeacon shows what a job that requires a DateTimeImmutable looks like when it cannot be dispatched.
The demo jobs also declare a run, derived from a value they were queued with, so the two features can be exercised together: dispatch AssembleSprocket twice with the same blueprint — say nightly.json — and both land in demo-assembly:nightly.json. Open either one and cancel run stops both. Run composer serve rather than serve:demo for this: run cancellation acts on real Redis, and the demo flow source has no Redis behind it.
Upstream Horizon
HorizonFlow is derived from Laravel Horizon and keeps its existing dashboard, queue supervision, metrics, and worker configuration. Refer to the Laravel Horizon documentation for inherited Horizon behaviour.
Laravel Horizon was created by Taylor Otwell and is maintained by Laravel and its contributors. HorizonFlow retains Laravel Horizon's original copyright and license notices. Issues caused by HorizonFlow changes should be reported in this repository; bugs that also exist in unmodified Laravel Horizon may belong in the upstream issue tracker. Upstream Laravel Horizon has its own release process.
Contributing
Contributions are welcome. Please read the contribution guide and Code of Conduct before opening an issue or pull request.
Security
Do not disclose security vulnerabilities in public issues. Follow this repository's security policy to report them privately.
License
HorizonFlow is released under the MIT license. The original Laravel Horizon copyright and license notice are retained.

