devaction-labs/zenith
| Install | |
|---|---|
composer require devaction-labs/zenith |
|
| Latest Version: | 0.6.0 |
| PHP: | ^8.5 |
| License: | MIT |
| Last Updated: | Sep 13, 2026 |
| Links: | GitHub · Packagist |
Zenith
The operator console and orchestration layer for Laravel Horizon.
Zenith is the DevAction Labs fork of nckrtl/horizon-new-dawn, renamed and extended. The original authors retain copyright under the MIT license.
[!IMPORTANT] Zenith is pre-1.0 software. Production use is supported only within the operating envelope documented below, and minor releases may contain documented breaking changes.
Zenith replaces Laravel Horizon's bundled interface with a package-owned React 19 and Inertia 3 application while keeping Horizon's authorization, repositories, metrics, and queue workers in charge.
The package reads Horizon data in PHP and sends structured page props through Inertia. It does not add a second general browser-facing API layer. Horizon's API routes remain available; where Zenith wraps a mutating handler, it preserves Horizon's authorization and adds only the safety or execution behavior documented below.

Beyond the original Horizon interface
Compared with Horizon's bundled interface, Zenith adds:
- full retained-source search for pending, completed, and silenced jobs by case-insensitive partial job class or exact retained ID, composable with exact job class, queue, connection, and pending-state filters;
- Horizon's exact failed-tag filtering and fast client-side sorting across every job row currently loaded by infinite scrolling;
- exact database-backed batch search, status tabs, queue and connection filters, creation ranges, counts, and server-side sorting;
- Horizon instance management, with controls to pause or continue individual local instances and terminate local instances;
- supervisor controls and detailed configuration views covering scaling, balancing, process limits, memory, timeouts, retries, backoff, and configuration warnings;
- queue management, including timed or indefinite pauses, Laravel 13.25 global pause/resume of every queue on every connection, clearing one or all queues, and retrying failures for a specific queue;
- individual and bulk cancellation of eligible pending jobs, while protecting batched jobs so they are cancelled through their batch;
- bulk and scoped failure recovery, with controls to retry or remove one failed job, retry or clear all failures, and retry failures by queue, monitored tag, or batch;
- batch management, including cancelling active batches, retrying failed batch jobs, clearing retained failures, and clearing finished batches;
- unique and encrypted job contracts plus downstream Bus chain steps on job detail;
- a Schedule page for Laravel scheduler events, with next-run times, overlap flags, and on-demand runs, alongside runtime-editable dynamic cron rows;
- durable workflow DAGs (
WorkflowDefinition) with named steps, dependencies validated before dispatch, cascade outputs, nested sub-workflows, compensation steps, per-step retry and backoff, and cancel/retry from the dashboard; - Signals and Relay, which release the current queue job while waiting for an external decision or a relayed result instead of blocking a worker, and Chunks, which batch items atomically until a size or time threshold is reached (these run as Horizon jobs or cache/database state; they do not replace Horizon workers);
- Backfills that queue each page as its own retryable job when built from an invokable class name (a closure-based backfill still runs its pages in-process);
- queue budgets (
QueueBudget,EnforceQueueBudget) that enforce rate limits and concurrency slots atomically across workers; - an opt-in, event-driven telemetry recorder powering live per-second throughput and wait/runtime percentile charts, per-attempt error history, and an in-flight "Executing" view with a per-node breakdown, falling back to Horizon's own snapshots while disabled;
- durable, retention-pruned job history (
zenith_job_history) for terminal job outcomes beyond Horizon's own retained window; - a workflow DAG graph view alongside the workflow table, a lifeline that repairs steps stuck past their timeout, and graceful interruption;
- schedule run history and a global scheduler pause, alongside the runtime-editable dynamic cron rows above;
- runtime supervisor process scaling, multi-select bulk retry and cancel across job tables, and retrying completed or silenced jobs;
- Laravel 13 job attributes (tries, backoff, timeout, uniqueness, debounce, routing, and more) shown on job detail without instantiating the job;
- a Horizon-bypass warning for jobs routed through Laravel's
failover,deferred, orbackgroundqueue drivers; - payload redaction and a
tag:search qualifier across every job list; - a per-user refresh-rate selector and keyboard shortcuts for navigation and search;
- engine-level job-class attributes (
#[GlobalLimit],#[RateLimit],#[Partition],#[ChainBy],#[Recorded]) enforced through job middleware, plus anti-starvation alerting; Workflow::fake(),Signal::fake(),Relay::fake(), anddrainWorkflow()test helpers for orchestration primitives;- a transactional outbox for job dispatch, and a
zenith:export-metricscommand exporting Prometheus samples for external autoscalers; - an AI-assisted failure-explanation hook (
Zenith::explainFailureUsing()) on the failed-job detail page.
Roadmap
Zenith reached parity with Oban Pro and Oban Web across the milestones below, built on Horizon and Laravel 13 primitives; the capabilities they describe are listed in Beyond the original Horizon interface above. Work is tracked as GitHub issues; #44 is the overview, kept open as the ongoing home for further parity and engine work.
| Milestone | Focus | Status |
|---|---|---|
| P1 · Telemetry | Event-driven telemetry: live throughput, wait and runtime percentiles, attempt history, and durable job history. | Complete |
| P2 · Dashboard parity | Workflow graph, cron history, runtime scaling, multi-select bulk actions, and payload redaction. | Complete |
| P3 · Engine | Global limits and partitions, ordered chains, recorded output, a transactional outbox, and a workflow lifeline. | Complete |
Renamed from Horizon New Dawn
Zenith was previously developed as Horizon New Dawn. Every package identifier changed with the rename:
| Before | After |
|---|---|
devaction-labs/horizon-new-dawn |
devaction-labs/zenith |
DevactionLabs\HorizonNewDawn |
DevactionLabs\Zenith |
php artisan horizon-new-dawn:* |
php artisan zenith:* |
config/horizon-new-dawn.php |
config/zenith.php |
horizon-new-dawn.* Gates |
zenith.* Gates |
horizon_new_dawn_* tables |
zenith_* tables |
public/vendor/horizon-new-dawn/build |
public/vendor/zenith/build |
The previous name was never published to Packagist, so there is no automatic upgrade path. Applications that installed a pre-release build from source should run php artisan zenith:install and php artisan migrate, which creates the new zenith_* tables, rename any horizon-new-dawn.* Gate definitions, and drop the old horizon_new_dawn_* tables after copying any audit history they want to keep.
Requirements
- PHP 8.5 or newer
- Laravel 13.23 or newer
- Laravel Horizon 5.46.0 or newer within the 5.x series
These floors are deliberate:
- PHP 8.5 is the lowest PHP version covered by the package's release matrix. Zenith does not claim compatibility with runtimes it does not continuously test.
- Laravel 13.23 is required by
pestphp/pest-plugin-laravel5. Laravel 12 is no longer part of the supported contract. - Horizon 5.46.0 is the oldest Horizon release exercised by Zenith's full package suite, real Redis worker smoke test, and consuming-application browser checks. Older Horizon releases are not part of the supported contract.
Queue pausing is available throughout the supported Laravel 13 matrix. Pausing or resuming every queue at once (Queue::pauseAll() / Queue::resumeAll()) additionally requires Laravel 13.25 or newer; earlier versions hide only those global controls.
Standalone Redis 6.2 or newer and standalone Valkey 8 are supported with
Predis and PhpRedis. Redis Cluster is supported by copying Horizon source
sets into hash-tagged package keys before multi-key writes, so ZDIFFSTORE
does not cross slots. Leave extra memory headroom for those snapshots.
Use maxmemory-policy noeviction for the Redis or Valkey instance that stores
Horizon queues and Zenith's retained-job indexes, and provision enough memory
for the configured retention windows. An allkeys-* policy may silently remove
queue or index keys under pressure. With noeviction, writes fail visibly
instead; monitor memory use and command errors so capacity can be increased
before that happens. Leave headroom beyond the persistent retained indexes:
reconciliation, search, and filtered queries use short-lived Redis union and
intersection sorted sets, while pending-state requests may copy live queue
structures into temporary snapshots.
Zenith reads and mutates batches through the application's configured
BatchRepository, including Laravel's database and DynamoDB implementations.
Exact full-history batch search, status and creation filters, counts, and
sorting require Laravel's DatabaseBatchRepository. Queue and connection
filters, queue-attributed batch summaries, and queue-level batch retry also
require Zenith's metadata migration; those destination-dependent features are
hidden until the migration has run, while the source-column SQL features remain
available. With another repository, Zenith keeps generic batch browsing and
actions but hides the query controls it cannot make exact. This does not make
DynamoDB a Horizon queue backend: Horizon still requires Redis for queues and
supervisors. The exact SQL query path supports MariaDB, MySQL, PostgreSQL, and
SQLite; applications using another database driver receive the same truthful
generic fallback.
Bulk mutations run as package-owned queued jobs. The configured bulk-operation
connection must therefore use an asynchronous queue driver that is processed by
Horizon. Zenith refuses these operations on sync and null drivers.
Retry all and Clear all on the failed-jobs page use Horizon's global retained
failure set at the moment the coordinator job starts, not the current filter
result. The HTTP request only authorizes, validates the bulk connection, and
enqueues one coordinator; workers process every eligible retained target in
bounded snapshot chunks with safe continuations.
Installation
Install and configure Laravel Horizon in the host application first. Then install Zenith and publish its compiled assets:
composer require devaction-labs/zenith:^0.3.0
php artisan zenith:install
php artisan migrate
The installer publishes configuration and compiled assets, and appends
@php artisan zenith:assets --ansi to the root application's
scripts.post-autoload-dump so later composer install and composer update
runs refresh published assets automatically. Use --no-composer-hook to skip
that edit. Pass --force only when you intentionally want to republish an
otherwise current build.
Visit the host application's existing Horizon path, which is /horizon by default. Zenith honors Horizon's configured path and domain and uses Horizon's existing authorization callback and middleware.
After the first install, package updates usually need no extra asset step:
Composer’s post-autoload-dump runs zenith:assets and replaces the
package-owned public/vendor/zenith/build directory when the published
build is missing, partial, stale, or contains files not present in the current
package build. An exact match is a no-op. Republish config or re-check
production prerequisites with the full installer:
php artisan zenith:install
php artisan migrate
Publish or refresh assets alone with:
php artisan zenith:assets
If Composer scripts are disabled (--no-scripts) or the hook was not added,
run php artisan zenith:assets after installs and updates. No Node.js
or frontend build is required in the consuming application.
Production deployment
Zenith's interface includes actions that mutate Horizon, Redis, and batch state. Validate a release against a production-like environment and backup or retention policy before exposing those actions to operators.
Zenith uses Horizon's existing Horizon::auth callback, normally backed by
the viewHorizon Gate, as the admission boundary. After that, optional Gates
can restrict mutations:
Gate::define('zenith.pauseQueues', fn ($user) => $user->isAdmin());
Gate::define('zenith.clearQueues', fn ($user) => $user->isAdmin());
Gate::define('zenith.retryJobs', fn ($user) => $user->isOperator());
Gate::define('zenith.cancelJobs', fn ($user) => $user->isAdmin());
Gate::define('zenith.manageInstances', fn ($user) => $user->isAdmin());
Gate::define('zenith.manageMonitoring', fn ($user) => $user->isOperator());
Gate::define('zenith.manageBatches', fn ($user) => $user->isAdmin());
Undefined Gates remain allowed for anyone Horizon already admitted. Successful
mutations are written to the application log and, after migrate, to
zenith_audit_events (visible at /horizon/audit).
Choose a durable, asynchronous connection and a queue that is consumed by Horizon for bulk operations:
'bulk_operations' => [
'connection' => 'redis',
'queue' => 'horizon-maintenance',
],
Bulk-operation jobs inherit the consuming Horizon supervisor's timeout. Keep
that timeout several seconds below the selected Redis connection's
retry_after, and configure the process manager's shutdown allowance above the
longest permitted job runtime. Each continuation performs a fixed-size page of
work and requeues itself until the point-in-time target snapshot is exhausted,
so supervisor timeouts need only cover one chunk plus snapshot bookkeeping.
A successful dispatch response confirms only that the bulk coordinator entered the queue. Temporary Redis snapshot state is namespaced and renewed while continuations run; it is not limited by a one-hour runtime or item-count ceiling. Abandoned state that is never renewed eventually expires, and a later continuation then fails closed with an explicit missing-state error. Snapshot members are acknowledged only after a terminal outcome so interrupted chunks can be retried without silently skipping unprocessed targets. Completion and failure records are written to the application log, and execution failures also appear in Horizon's failed-jobs view for inspection and retry.
Global failed-job retry and clear actions are never rejected solely because the retained count is large. New failures retained after the coordinator captures its snapshot are outside the operation boundary and do not make the run endless.
Repository-wide batch scans used by summaries, filter catalogs, and generic fallback bulk preflights walk the complete retained history page-by-page with no configurable total ceiling. Malformed or non-advancing repository pages fail closed. Database-backed search, status and creation filters, counts, sorting, and SQL batch-clear classification keep their optimized paths. Queue and connection filters additionally require the package metadata migration.
Applications using Laravel's database batch repository should schedule
queue:prune-batches with retention options appropriate for that application.
Applications using Laravel's DynamoDB batch repository must configure the
repository's native TTL through queue.batching.ttl_attribute and
queue.batching.ttl instead; Laravel's batch-pruning command does not prune
DynamoDB batches.
Set Horizon's trim.pending visibility window longer than the longest delay or
release interval used by batch jobs. Otherwise Horizon may trim pending
metadata before a destructive preflight can see an active delayed job.
For applications that cache routes or configuration, use this update order:
php artisan optimize:clear
composer install --no-dev
php artisan migrate --force
php artisan config:cache
php artisan route:cache
When Composer scripts run, post-autoload-dump refreshes Zenith assets via
zenith:assets. With --no-scripts, or when shipping immutable
artifacts that already include public/vendor/zenith/build, skip the
Artisan publish step on deploy nodes. Otherwise run
php artisan zenith:assets (or the full installer) on each node after
Composer. Publication replaces the whole package-owned
public/vendor/zenith/build tree from a validated staged copy, so a
request either sees the previous tree or the new one—not a mixed generation.
There can be a brief gap while the destination is swapped.
If you are adopting the retained-job filters in an existing production Horizon deployment, you may optionally prebuild both request-reconciled indexes once after deployment:
php artisan zenith:warm-batch-metadata
php artisan zenith:warm-retained-jobs
Run zenith:warm-batch-metadata only after the package migration is
present. The retained-job warm command does not require that SQL table. Neither
warm-up is required on every deploy. Zenith still reconciles newly retained
records on requests; the commands exist to keep the first operator request from
paying the initial full-history reconciliation cost.
Request reconciliation adds newly retained jobs immediately, while every exact
query intersects a point-in-time Horizon source snapshot so expired projection
members cannot appear in results. Removing stale projection members is
maintenance rather than a serving requirement. Long-lived, high-throughput
applications should therefore schedule zenith:warm-retained-jobs
outside peak traffic at a cadence appropriate for their Horizon retention and
throughput, using Laravel's withoutOverlapping() guard. This bounds package
index storage without making page correctness depend on the scheduler.
If another reconciliation already owns a retained-type synchronization lock,
the warm command fails instead of claiming that stale cleanup completed; rerun
it after that reconciliation finishes.
The projection stores immutable metadata and facet memberships for Horizon's
retained jobs, so its Redis memory use and first reconciliation cost scale with
the application's configured Horizon retention windows. Tune
Horizon's trim values for the history operators actually need, and monitor
Redis memory and command latency after enabling the filters. Pending-state
filters remain request-time rather than persistently cached. Each filtered
request atomically copies the relevant ready or reserved structure, or stores
the matching delayed score window, into short-lived snapshot keys before
scanning it in chunks. The snapshot TTL is renewed while it is read and the
keys are deleted afterward. This prevents workers from shifting live offsets
and silently omitting jobs, but its request work and transient memory scale
with the selected queues' current backlog. A released-state request may
temporarily hold both a ready-list copy and the due portion of the delayed set.
Leave Redis or Valkey headroom for that snapshot in addition to the persistent
projection and temporary query intersections.
Zenith excludes its configured Horizon route tree from the host application's Inertia SSR gateway because the package intentionally ships a client-only bundle. It also uses Laravel's Vite CSP nonce for its bootstrap script, package Vite entry tags, and Inertia runtime styles.
Some React components use inline style attributes for runtime geometry. CSP
deployments must permit those attributes; policies with
style-src-attr 'none' are outside the supported production envelope.
After package or configuration changes, restart Horizon and any long-lived application process such as Octane or FrankenPHP so workers do not retain old code or configuration:
php artisan horizon:terminate
php artisan octane:reload
Use the command appropriate for the host runtime when Octane is not installed.
The built frontend explicitly targets Vite's baseline-widely-available
browser set.
To populate the metrics pages, schedule Horizon's snapshot command every five minutes in the host application's routes/console.php:
use Illuminate\Support\Facades\Schedule;
Schedule::command('horizon:snapshot')->everyFiveMinutes();
If durable job history is enabled, schedule the prune command outside peak
traffic with Laravel's withoutOverlapping() guard so a slow run is never
started twice:
Schedule::command('zenith:prune-history')->daily()->withoutOverlapping();
Rollback by restoring the prior Composer lock file and application release, running that release's installer, rebuilding Laravel's caches, and restarting the long-lived processes. Redis Cluster remains outside the supported production envelope.
Reverting to the original Horizon interface
Remove Zenith, reinstall Horizon's resources, and clear the application's cached configuration and routes:
composer remove devaction-labs/zenith
php artisan horizon:install
php artisan optimize:clear
The original Horizon interface will then be available at the application's existing Horizon path. Current Horizon releases load their compiled interface assets directly from the laravel/horizon package, so the deprecated horizon:publish command is not required.
Interface
Zenith provides dedicated Inertia routes for:
- the dashboard, system status, supervisors, workload, throughput, and wait times;
- pending, completed, silenced, and failed job lists with job detail pages;
- retrying one failed job or all failed jobs;
- monitored tags with completed and failed job views;
- job and queue metrics with historical snapshots;
- job batches, batch search, batch progress, failed jobs, and batch retry.
The interface uses a persistent responsive layout composed from shadcn/ui primitives. It supports light, dark, and system themes; exact server-sorted database batch tables; client-sorted job tables; Inertia-powered infinite scrolling; optional automatic refreshes; responsive navigation; and Inertia mutations with toast feedback. Generated Wayfinder routes are rebased at runtime, so links and actions continue to work with a custom Horizon path or an absolute domain URL.
On the pending, completed, and silenced Jobs pages, search examines Horizon's complete retained source before the 50-row page is selected. A term matches any job class that contains it case-insensitively, or the retained job whose ID matches it exactly. Those alternatives are combined with OR, then intersected with any active exact job class, queue, connection, and pending-state filters. The Failed Jobs search remains Horizon's exact tag query.
Zenith maintains package-owned Redis projections of immutable job metadata and reconciles them from Horizon on requests; it does not require an event listener, scheduler, or background indexing process. Full-source search reuses that projection and adds no persistent search-specific index. When a search matches multiple class facets or an exact retained ID, Zenith creates only short-lived Redis union and query keys, deletes them after the request, and retains a short expiry as cleanup fallback.
Unfiltered list totals and time-window counts read Horizon's retained sorted sets directly. Exact filter catalogs share one retained-source snapshot per job type, expose only values still represented in that snapshot, and reuse a job's cached immutable class, queue, connection, and tags when it moves between pending and terminal history. The browser requests a catalog only when the filter trigger is hovered, focused, or opened. Normal list polling excludes catalogs, and successful catalog responses are reused for one configured poll interval.
Partial job-class search unions every matching retained class. Redis union and catalog work is split into internal bounded command chunks so no single command receives an unbounded key list and retained job records are not fully materialized. A term that matches every retained class uses the existing projection directly. Intent-loaded exact-filter catalogs return every distinct current job class, queue, and connection value, reading Redis catalog data in internal bounded pages; the final option list is output-sized.
Pending rows retain Horizon's enqueue chronology while their ready, delayed, released, or reserved state is resolved from a point-in-time snapshot of the live queue structures.
Sortable job headers on the main job lists and queue activity views reorder every row currently loaded in the browser. Sorting does not make another request and is intentionally not a global retained-history sort. Infinite scrolling still requests the next 50 rows in Horizon's retained order; after those rows arrive, the combined loaded collection is sorted again. Numeric nulls remain last in either direction. Pending jobs support Job and Queued; completed and silenced jobs additionally support Completed and Runtime; failed jobs support Job, Runtime, and Failed. Monitored-tag lists keep Horizon's repository order.
Pending State remains an exact request-time server filter. Its badge can change when a job moves between ready, reserved, and delayed structures or crosses its release time. Each state-filter request uses one immutable, point-in-time queue snapshot so a worker cannot make the scan itself omit members. Jobs explicitly made available by Zenith are classified as Ready, matching their displayed badge; naturally elapsed schedules remain Released.
For database-backed batches, Zenith stores one immutable queue and connection
snapshot per retained batch in zenith_batch_metadata. Explicit batch
options are preserved. When an older batch omitted an option, its value is
inferred once from the application's queue configuration when Zenith first
discovers that batch. The interface labels that provenance because a default
that changed before first discovery cannot prove the batch's historical
physical destination. Once captured, the snapshot does not change when
configuration changes. Lifecycle counts and status always come directly from
Laravel's job_batches table.
Horizon API routes remain available under Horizon's existing authorization
boundary. Read responses keep Horizon's existing contract. Monitoring
mutations also apply Zenith's reserved-key and currently-monitored tag guards,
while bulk operations retain their bounded asynchronous execution safeguards.
Unsupported legacy UI paths return 404 instead of silently falling back to
Horizon's Vue application.
Configuration
Publish the configuration when you need to change defaults:
php artisan vendor:publish --tag=zenith-config
return [
'poll_interval' => 5000,
'job_navigation_breakdown' => false,
'job_payload_allowed_classes' => [],
'redact_payload_keys' => [
'password',
'token',
'secret',
'key',
'authorization',
],
'supervisor_scale_bounds' => [
'min' => 1,
'max' => 20,
],
'bulk_operations' => [
'connection' => null,
'queue' => null,
],
'signals' => [
'store' => null,
'ttl' => 86400,
],
'relay' => [
'store' => null,
'ttl' => 3600,
],
'chunks' => [
'store' => null,
'ttl' => 86400,
],
'history' => [
'enabled' => false,
'retention' => [],
],
'telemetry' => [
'enabled' => false,
'node' => null,
'retention' => [/* ... */],
'in_flight' => [
'default_timeout_seconds' => 60,
'grace_seconds' => 60,
],
'attempts' => [
'per_job_limit' => 25,
'ttl_seconds' => 604800,
],
],
'schedule_history' => [
'store' => null,
'ttl' => 604800,
'limit' => 10,
],
'dynamic_cron_allowed_classes' => [],
'queue_failover' => [
'store' => null,
'window_minutes' => 60,
],
'chains' => [
'store' => null,
],
'recorded' => [
'store' => null,
'ttl' => 86400,
],
'starvation' => [
'threshold_seconds' => 300,
],
];
Set job_navigation_breakdown to true to show Pending, Failed, Completed,
and Silenced links below the Jobs navigation item. It is hidden by default;
the Jobs item itself always links to Pending.
job_payload_allowed_classes is a legacy compatibility escape hatch for
releasing unique-job locks from serialized queue commands. Keep it empty unless
an exact trusted job class must be instantiated; the safe default relies on
Laravel's serialized queue context and never instantiates arbitrary payload
classes. PHP deserialization may invoke lifecycle methods such as __wakeup
and __destruct, so allow only side-effect-free classes controlled by the
application.
redact_payload_keys masks job argument, failed-job argument, and workflow
step output and context values before they reach Inertia whenever a key
contains one of the configured strings, case-insensitively, at any depth. Call
DevactionLabs\Zenith\Zenith::redactPayloadUsing(callable $callback) from your
own service provider to replace that key-pattern matching entirely with
application-specific redaction; the callback receives the full payload array
and its return value is used as-is.
signals, relay, and chunks each choose the cache store that backs
that feature (the default store when null) and how long, in seconds, a
signal, a relayed result, or a buffered chunk is kept before it expires. The
chosen store must support atomic locks (Redis, database, memcached, and array
qualify; file and cookie stores do not). A job that calls Signal::await() or
Relay::await() and wants to be released and redelivered while it waits,
instead of occupying a worker, must add new \DevactionLabs\Zenith\Signals\ReleaseWhileWaiting to its middleware()
method — RunWorkflowStep already does this for workflow steps. Give a
waiting job's class a retryUntil() deadline rather than a fixed $tries, so
being released while it waits never exhausts its attempts.
history.enabled opt-ins into durable job history: once true and migrated,
zenith_job_history stores one row per terminal job attempt (class, queue,
connection, status, attempts, runtime, tags, a short error summary, and
pushed/completed/failed timestamps) with no raw payload or exception trace.
history.retention is an ordered list of rules, each with an optional
queue, class, and/or status filter and a required days value, for
example:
'history' => [
'enabled' => true,
'retention' => [
['queue' => 'emails', 'days' => 7],
['status' => 'failed', 'days' => 90],
['days' => 30],
],
],
zenith:prune-history deletes rows older than their matching rule's days.
Rules are evaluated in the listed order and the first one whose filters match
a row governs it; list a rule with no filters last, since it is a catch-all
and nothing after it can ever match. Rows matched by no rule are kept
indefinitely rather than pruned by guesswork.
telemetry.enabled opt-ins into the event-driven metrics recorder that
powers the dashboard's live throughput and percentile charts, per-attempt
error history, and the in-flight "Executing" view; it adds no queue-event
listeners and writes nothing to Redis while disabled. telemetry.node
overrides the node identity recorded for every event on a host (the local
hostname by default). telemetry.retention configures the fine (1s),
standard (1m), and coarse (5m) bucket widths and how long each tier is kept.
telemetry.in_flight bounds how long an "executing" entry survives without a
terminal event, derived from a job's own Timeout attribute plus a grace
period. telemetry.attempts bounds the per-job attempt history shown on job
and failed-job detail, both by count and by a TTL.
supervisor_scale_bounds clamps the minimum and maximum process count
accepted by the runtime supervisor-scaling control; a supervisor's own
configured minProcesses/maxProcesses take precedence when Horizon exposes
them.
schedule_history chooses the cache store, TTL, and per-event run limit for
the run history recorded on the Schedule page. dynamic_cron_allowed_classes
is a required allowlist of job classes that can be scheduled by name from the
dynamic-cron dashboard controls; a class not on the list is rejected.
queue_failover sets the cache store and lookback window for tracking recent
Illuminate\Queue\Events\QueueFailedOver occurrences, surfaced as the
Horizon-bypass warning banner.
chains and recorded choose the cache store (plus, for recorded, a TTL)
backing #[ChainBy] ordered-chain enforcement and #[Recorded] job-output
capture, respectively.
starvation.threshold_seconds is how long a queue's oldest ready job may age
before QueueStarvationAlert flags that queue as starved on the dashboard and
queues page; the alert never reprioritizes or moves jobs itself.
Testing
Workflow, Signal, and Relay each expose a fake(), mirroring Laravel's
Bus::fake() ergonomics:
use DevactionLabs\Zenith\Workflows\RunWorkflowStep;
use DevactionLabs\Zenith\Workflows\Workflow;
use DevactionLabs\Zenith\Workflows\WorkflowDefinition;
use Illuminate\Support\Facades\Bus;
Workflow::fake();
$workflow = WorkflowDefinition::make('onboarding')
->add('provision', ProvisionAccount::class)
->dispatch();
Workflow::assertDispatched('onboarding');
Bus::assertNotDispatched(RunWorkflowStep::class);
Workflow::fake() fakes the bus too, so there is no separate Bus::fake()
call to make. dispatch() still persists the real workflow and step rows —
the step above ends up WorkflowStatus::Dispatched — but RunWorkflowStep is
never actually queued. Workflow::assertDispatched() takes no argument to
assert any workflow was dispatched, a string to match a dispatched workflow's
name(), or a closure that receives each dispatched WorkflowDefinition:
Workflow::assertDispatched(
fn (WorkflowDefinition $definition) => $definition->name() === 'onboarding'
&& count($definition->steps()) === 1,
);
Call Workflow::fake() again for a clean slate; it forgets every previously
recorded dispatch.
Signal::fake() and Relay::fake() swap the store the feature reads and
writes for an isolated in-memory one, so a test can send and await signals, or
record and await relayed results, without configuring a real cache:
use DevactionLabs\Zenith\Relay\Relay;
use DevactionLabs\Zenith\Signals\Signal;
Signal::fake();
Signal::send('approval', ['approved' => true]);
expect(Signal::pull('approval'))->toBe(['approved' => true]);
Relay::fake();
Relay::record('job-1', ['ok' => true]);
expect(Relay::await('job-1', seconds: 5))->toBe(['ok' => true]);
Calling either again also gives a clean slate.
Faking any of the three stops queued jobs from running, so a workflow
dispatched under Workflow::fake() (or a plain Bus::fake()) stops at its
first claimed step. Zenith's own Pest suite drains one to a terminal status
in-process instead of through a real queue worker:
$workflow = drainWorkflow($workflow);
expect($workflow->status)->toBe(WorkflowStatus::Completed);
drainWorkflow() repeatedly runs whichever claimed step or compensation the
workflow is waiting on — exactly as RunWorkflowStep or
RunWorkflowCompensation would — until nothing is left to claim. It does not
retry a failed step: run directly instead of through a queued job, a step
fails immediately on its first exception, the same as the sync queue driver.
It is a plain test-support function, defined in
tests/Support/WorkflowDrain.php and wired into tests/Pest.php the same way
tests/Support/WorkflowTables.php's migrateWorkflowTables() is; an
application testing its own workflows can copy the same short helper into its
own test suite.
Development
composer install
bun install
php vendor/bin/testbench workbench:build
composer quality
bun run format:check
bun run lint
bun run test
bun run typecheck
bun run build
On local machines, composer test uses Pest's
test impact analysis: it re-runs only the tests
affected by your changes and replays the rest from cache. The first run either
downloads the dependency graph that the TIA Baseline workflow publishes from
main (requires an authenticated gh) or records one locally, which needs
Xdebug or PCOV. Run composer test:full to execute every test, or
vendor/bin/pest --tia --fresh to record the graph again. CI always runs the
complete suite.
Regenerate typed Horizon route helpers after route changes:
bun run wayfinder:generate
The package includes an Orchestra Workbench application with deterministic successful and failing queue jobs for exercising the interface.
Continuous integration and releases
Every pull request runs the Tests workflow on GitHub-hosted runners: the
Laravel and Horizon compatibility matrix, the retained-job Redis matrix, a
dependency audit, and the PHP and frontend quality checks. A single aggregate
status check named CI passes only when all of them pass, and main accepts
only pull requests whose CI check is green.
Pull requests opened from this repository enable auto-merge as soon as that
check passes, so they merge on their own with a merge commit unless they are
drafts. The Auto Merge job needs the organization GH_PAT secret to be shared
with the repository.
Releases come from the separate Release workflow, which runs only after the
Tests workflow succeeds on a push to main. To publish one, add the
release:patch, release:minor, or release:major label to the pull request
before merging it. The workflow checks that the merged commit's CI check passed,
tags the next semantic version, and publishes a GitHub release whose notes come
from that version's CHANGELOG section, falling back to generated notes when the
section is missing. Packagist picks up the tag through its GitHub integration.
Maintainers can release an explicit version from Actions → Release → Run
workflow on main. The first Zenith release, 0.2.0, is cut that way because
there is no earlier tag to bump from; later releases can use the labels.
License
Zenith is open-source software licensed under the MIT license.
Related Packages
Enterprise Laravel Livewire CRUD generator with advanced analytics, calendar man...
A driver-agnostic control center for Laravel queues, jobs, commands and the sche...
Config-driven CRUD scaffolding for Laravel + Inertia.js (React/TypeScript) with...
Modern admin dashboard boilerplate - Laravel 13 + Inertia.js + React 19 + TypeSc...