laravel-chronoview maintained by grazulex
Laravel ChronoView
[!TIP] What Laravel ChronoView does for you — Stop discovering that a scheduled task silently stopped running weeks ago. ChronoView gives your Laravel scheduler a Horizon-style dashboard: every run, its output, its failures — and the runs that never happened. Zero changes to your tasks.
This package is free and maintained on my own time. If it saves you hours, a small contribution helps me keep it going: 💖 GitHub Sponsors · ☕ Buy Me a Coffee · PayPal
Horizon, but for the scheduler.
Features
- Zero instrumentation — listens to the native scheduler events; no
->monitor()to chain on every task - Every run recorded — status, trigger, due time, start, end, duration, exit code, host, captured output, exception
- Missed-run detection — a due minute with no run and no skip is flagged as
missed - Scheduler heartbeat — the dashboard turns red when
schedule:runitself stopped - Human-readable cron — common expressions (
* * * * *,0 */2 * * *, …) are described in plain English, with the raw expression as fallback - Run now & Pause / Resume — without redeploying
- Public events —
TaskRunFailed,TaskRunMissed,SchedulerDownfor your own alerts - Self-contained UI — Blade + embedded Alpine.js 3.17.3, light/dark theme, no build step, no CDN
- Database storage — any connection, automatic pruning; no Redis required
Requirements
- PHP 8.3+
- Laravel 12.x or 13.x
Installation
composer require grazulex/laravel-chronoview
php artisan chronoview:install
That's it. Keep your usual cron entry (* * * * * php artisan schedule:run) — ChronoView schedules its own chronoview:check (every minute) and chronoview:prune (daily).
Open /chronoview. In local it is open; elsewhere, authorise access like Horizon — see Authorization.
Authorization
ChronoView is open in the local environment and returns 403 everywhere else until you choose one of these (checked in this order):
- A callback —
ChronoView::auth(fn ($request) => $request->user()?->isAdmin()); - A gate —
Gate::define('viewChronoView', fn (User $user) => $user->isAdmin()); - An allow-list of emails, no code needed — in
.env:CHRONOVIEW_ALLOWED_EMAILS=jms@example.com,ops@example.comThe visitor must be authenticated (default guard, orCHRONOVIEW_GUARD) with a listed email — this also applies inlocal.
Unauthenticated visitors get a 403 (not a login redirect), like Horizon.
Screens
| Overview | Task |
|---|---|
| Scheduler alive/down, 24 h KPIs, tasks needing attention, up next, recent problems | Cron in plain English, 7-day summary, duration sparkline, paginated history, Run now / Pause |
The task page shows the description and the PHP file (and line, for closures) that defines the task.
(screenshots to add before the LinkedIn / Laravel News post)
How it works
ChronoView never touches routes/console.php. It only listens to the scheduler's own events and records what it sees.
1. Decoration, at the start of schedule:run/work/test/finish. ScheduleEventSubscriber listens to CommandStarting for these four commands. At that exact moment the schedule is fully built (routes/console.php has already run), so ScheduleInspector::decorate() can safely walk every event and:
- add a
->when(...)filter that skips the task if its key is in the current list of paused tasks (loaded once per process, so a pause becomes effective on the very nextschedule:run); - redirect its output to
storage/framework/chronoview/<key>.log, but only if nothing has redirected it yet — your ownsendOutputTo()is never overridden, and closures are never captured.
Any earlier hook (e.g. afterResolving(Schedule::class)) would fire before routes/console.php runs and would miss tasks, which is why the decoration happens on CommandStarting instead.
2. Recording a run. For every due task:
ScheduledTaskStarting→Recorder::starting()inserts arunningrow (expected due time, host, triggerscheduleormanual);ScheduledTaskFinished→Recorder::finished()finds that task's latestrunningrow in the database (never in memory), closes itsuccessorfailedfrom the exit code, and reads/truncates the captured output;ScheduledTaskFailed(thrown exceptions — closures, or an artisan command whose non-zero exit code Laravel turns into an exception) →Recorder::failed()attaches the exception trace to that same row. A failing artisan command therefore producesFinished(closes the run asfailed) thenFailed(adds the exception): one row, not two;ScheduledTaskSkipped→Recorder::skipped()records askippedrow, unless the skip was caused by ChronoView's own pause filter (that one is not worth logging);ScheduledBackgroundTaskFinished, fired byschedule:finishin a different process forrunInBackgroundtasks →Recorder::finished()'s database lookup is exactly what lets that other process find and close the samerunningrow.
3. The control loop. ChronoView registers its own chronoview:check command, every minute, and chronoview:prune, daily with onOneServer(). Each run of chronoview:check:
- records a heartbeat for the current host — unconditionally, on every host, every minute;
- re-syncs every task of the schedule (upsert,
seen_at = now) — also unconditionally; - acquires a shared cache lock (
chronoview:detect, 55 s) so detection runs once per cluster per minute rather than once per host — a shared cache (Redis, database, memcached) is required for this to work correctly across multiple servers; if the lock is held by another host, this run's heartbeat and sync still happened, detection is simply skipped for this minute. Stores without atomic locks (apc/apcu) still work: detection then runs on every host without the lock; - under that lock, runs
MissedRunDetector::detect()— for every active task seen recently, it looks at the last due minute older thancheck.graceseconds and flags itmissedunless a run or a skip already exists for it, or the task was created after that minute (a brand-new task can't have missed a run that predates it), or the task was resumed after that minute; - still under the lock, closes any
runningrow older thancheck.stale_afterasfailed(a run that never got itsFinished/Failedevent — crashed worker, killed process); - dispatches
SchedulerDownonce per outage, for any other host whose heartbeat is older thancheck.heartbeat_timeoutand that has not already been notified for this outage.
If the cron itself stops, chronoview:check stops too — that failure is caught separately by the heartbeat banner on the dashboard, not by SchedulerDown (which only covers other hosts in a multi-server setup).
Migrations are loaded automatically (loadMigrationsFrom); chronoview:install publishes the config, runs migrate and calls chronoview:sync.
Access is checked by ChronoView::check(): your ChronoView::auth() callback if you registered one, otherwise the viewChronoView gate if it is defined, otherwise the email allow-list if chronoview.auth.allowed_emails is configured, otherwise the local environment only. See Authorization.
Configuration
Publish the config with php artisan vendor:publish --tag=chronoview-config (done automatically by chronoview:install). All values can be overridden per key.
| Key | Default | Role |
|---|---|---|
enabled |
true (CHRONOVIEW_ENABLED) |
Master switch. false disables routes, listeners and the internal schedule entirely. |
path |
chronoview (CHRONOVIEW_PATH) |
URI prefix of the dashboard. |
domain |
null (CHRONOVIEW_DOMAIN) |
Optional domain for the dashboard routes. |
middleware |
['web', 'chronoview.auth'] |
Middleware stack of the dashboard routes. chronoview.auth enforces ChronoView::auth() / the viewChronoView gate / the email allow-list / the local environment. |
auth.allowed_emails |
null (CHRONOVIEW_ALLOWED_EMAILS) |
Comma-separated (env) or array (published config) list of emails allowed to access the dashboard — see Authorization. |
auth.guard |
null (CHRONOVIEW_GUARD) |
Auth guard used to resolve the user for auth.allowed_emails. null means the default guard. |
refresh |
15 (CHRONOVIEW_REFRESH) |
Auto-refresh interval of the dashboard pages, in seconds. 0 disables it. |
connection |
null (CHRONOVIEW_DB_CONNECTION) |
Database connection used for ChronoView's tables. null means the app's default connection. |
table_prefix |
chronoview_ |
Prefix of every table ChronoView creates. |
record.output |
true |
Capture the output of artisan/exec tasks. Closures are never captured. |
record.max_output |
65536 (64 KB) |
Bytes of output kept per run. When truncating, the end of the output is kept. |
record.skipped |
true |
Record ScheduledTaskSkipped as skipped runs (except skips caused by ChronoView's own pause). |
check.enabled |
true |
Register the internal chronoview:check (every minute) and chronoview:prune (daily) schedule entries. |
check.grace |
90 |
Seconds after a due minute before a task with no run and no skip is flagged missed. |
check.heartbeat_timeout |
180 |
Seconds without a heartbeat before a host's scheduler is considered down (SchedulerDown). |
check.stale_after |
21600 (6 h) |
Seconds after which a running run with no closing event is force-closed as failed. |
actions.run_now |
true |
Enable the "Run now" button/action. |
actions.queue_connection |
null (CHRONOVIEW_QUEUE_CONNECTION) |
Queue connection used by the RunScheduledTask job dispatched by "Run now". |
actions.queue |
null (CHRONOVIEW_QUEUE) |
Queue name used by the RunScheduledTask job. |
actions.pause |
true |
Enable the Pause / Resume actions. |
prune.keep_days |
14 |
Days of history kept by chronoview:prune (runs, tasks no longer seen, stale heartbeats). |
Events
Event::listen(TaskRunFailed::class, fn ($e) => Notification::route('slack', '…')->notify(new TaskFailed($e->run)));
Event::listen(TaskRunMissed::class, fn ($e) => /* $e->run->task->name, $e->run->expected_at */);
Event::listen(SchedulerDown::class, fn ($e) => /* $e->hostname, $e->lastBeatAt */);
SchedulerDown is dispatched once per outage, when a host's heartbeat first exceeds check.heartbeat_timeout; it fires again only after that host has beaten again.
Actions
- Run now dispatches the queued job
RunScheduledTask, which replaysstarting/finished/failedthrough the sameRecorderas a real scheduled run (triggermanual). It honoursactions.queue_connectionandactions.queue. - Pause / Resume flip a flag read once per
schedule:runprocess, so the effect is visible from the next run of the scheduler, not instantly.
FAQ
Why is a run marked missed? No run and no skip was recorded for that due minute within check.grace seconds (90 by default) and the task was already known before that minute. Typical causes: the cron stopped (see the heartbeat banner), the task is filtered by environments() on a server where record.skipped is disabled, or schedule:run executes foreground tasks sequentially, so a task whose predecessor runs longer than grace + 60 s is flagged missed and then runs anyway — give long tasks runInBackground() or raise check.grace.
A task is still running after hours. chronoview:check closes runs older than check.stale_after (6 h) as failed. Lower it if your tasks are always short.
Does it capture the output of closures? No — only artisan commands and exec() tasks. Laravel redirects their stdout to a file; ChronoView reads it and deletes it. Your own sendOutputTo() is never overridden.
Command output is captured in storage/framework/chronoview/ (git-ignored automatically); chronoview:prune removes files older than a day left by interrupted runs.
Does it slow down the scheduler? About seven cheap queries per run (task upsert + run insert at start; task/run lookups + two updates at the end) and one SELECT of paused keys per schedule:run process.
Which timezone are the times in? The application timezone by default (shown in the header); click the clock button to switch to your browser's local time. Each task also shows its own scheduling timezone.
Name your closures (->name('…')) — unnamed closures are identified by file and line, so moving code orphans their history.
Support This Package
💖 GitHub Sponsors · ☕ Buy Me a Coffee · PayPal
License
MIT — see LICENSE.md.