DocumentationSearch docs
  1. 01Getting started
  2. 02Next.js and Vercel
  3. 03Node servers and scripts
  4. More JavaScript platforms

    1. 04SvelteKit
    2. 05Nuxt and Nitro
    3. 06React Router and Remix
    4. 07NestJS
    5. 08Strapi
    6. 09Netlify
    7. 10Firebase
    8. 11Convex
    9. 12Trigger.dev
    10. 13Inngest
    11. 14Cloudflare Workers
    12. 15Supabase and pg_cron
  5. Ruby

    1. 16Ruby on Rails
    2. 17Ruby
  6. Python

    1. 18Django
    2. 19Celery
    3. 20Python
  7. PHP

    1. 21Laravel
    2. 22Symfony
    3. 23WordPress
    4. 24Drupal
    5. 25Craft CMS
    6. 26PHP
  8. Go

    1. 27Go
    2. 28Go schedulers
  9. Rust

    1. 29Rust
    2. 30Rust schedulers
  10. Elixir

    1. 31Elixir
    2. 32Elixir schedulers
  11. Java

    1. 33Java
    2. 34Java schedulers
  12. .NET

    1. 35.NET
    2. 36.NET schedulers
  13. Reference

    1. 37Schedules, grace, and timeouts
    2. 38What it catches
    3. 39Alerts
    4. 40AI triage
    5. 41Stores
    6. 42Dashboard and API
    7. 43MCP server
    8. 44Agent skill
    9. 45TypeScript API reference
    10. 46Limits and design notes
    11. 47Environment variables
    12. 48Stability
    13. 49Deprecations

Laravel

Laravel

cronwatch/cronwatch is the PHP port of @cronwatch/sdk: the same conditions, the same alert text, and the same stored rows. In a Laravel app it watches every task in the schedule with no code changes, records queued jobs that opt in, schedules its own check, keeps its tables in the app’s database, and serves the dashboard behind a gate. It needs PHP 8.2 or newer and is tested on Laravel 12 and 13.

Install

composer require cronwatch/cronwatch
php artisan migrate

Package discovery registers the service provider, so there is nothing to add to bootstrap/providers.php. The migration it loads makes the three tables (cronwatch_jobs, cronwatch_runs, cronwatch_state) in the app’s database, so another language can share them: on SQLite or Postgres any port, on MySQL or MariaDB the Go, Rust, Elixir, Java, and .NET ports.

Then say where alerts go, in .env:

CRONWATCH_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...
CRONWATCH_MAIL_TO=ops@example.com
CRONWATCH_TOKEN=a-long-random-string

That is all. With no channel set, alerts are written to the app’s log.

Everything else is in config/cronwatch.php, and every value there reads the environment, so most apps never publish it. To keep a copy:

php artisan vendor:publish --tag=cronwatch-config
php artisan vendor:publish --tag=cronwatch-migrations   # optional: the migration, into database/migrations

What is watched

Every task in the app’s schedule (routes/console.php, or withSchedule() in bootstrap/app.php) is a job, and every run schedule:run makes of it is recorded, through the scheduler’s own events:

  • A run starts when Laravel starts the task, with the trigger "laravel-scheduler" (runs recorded before 0.11 carry "schedule", and a background task started then is still finished; see Triggers, tags, and job names).
  • A command that exits non-zero fails with Exited with code N; a callback that throws fails with the exception, and one that returns false with Returned false. A callback’s return value is treated as run()'s: a string is the output, and an HTTP response of 400 or more fails the run.
  • A command’s output is what it wrote: its own file (->sendOutputTo(), or ->appendOutputTo() read from where the file stood when the run started), or, for a task that sends its output nowhere (Laravel’s default), nothing. Set CRONWATCH_CAPTURE_OUTPUT=true to record that output too: it goes to a temporary file of CronWatch’s own for the run, which holds all of it until the run ends. At most the last 256 KB is read. A failed command’s output is kept with its error.
  • A task Laravel skips (a filter said no, the schedule is paused, ->withoutOverlapping() found the last run still going) records nothing.
  • A task ->onOneServer() is recorded on the server that ran it, and a task ->runInBackground() is finished by schedule:finish, which Laravel runs when the command ends, with its exit code and output. Sub-minute tasks (->everyTenSeconds()) record every run.

A command runs in a process of its own, so its output is how it reports. Inside a callback (Schedule::call()), Cronwatch\Cronwatch::current() is the run’s context:

Schedule::call(function () {
    $count = Order::prune();
    \Cronwatch\Cronwatch::current()?->log('Pruned', $count, 'orders');
    \Cronwatch\Cronwatch::current()?->metric('pruned', $count);
})->daily()->name('prune-orders');

Names and schedules

A job is named after:

  • a command, as written without php and artisan: emails:send. One with arguments has them too, with the characters a name cannot hold made - and a short hash added: emails:send --force is emails:send-force-3ad53ba5.
  • a shell command (Schedule::exec()), the same way.
  • a callback, after its ->name() or ->description(), else its class and method, else, for a closure, closure:<file>:<line>, which moves when the line does. Name your closures.
  • a queued job (Schedule::job()), after its class, backslashes as dots: App.Jobs.SendReport.

The schedule is the task’s own cron expression and timezone (->timezone(), else the app’s). What CronWatch expects is exactly what the scheduler runs, so a task that stops running is reported missed.

A task with filters (->when(), ->skip(), ->between(), ->unlessBetween()) does not run at every time its expression names, so its job has no schedule: its failures, slow runs, and stuck runs are still watched, and it is never reported missed. A task outside the current environment (->environments()) is not watched. Two tasks with one name on different schedules are one job without a schedule, reported once to the log; give each its own name.

Jobs are declared as schedule:run starts and at every check, so a task that has never run is known. A task taken out of the schedule keeps its history and is declared again without its schedule at the next check, so it is never reported missed. Jobs are tagged laravel-scheduler and laravel-scheduler:<app>, the app’s name (CRONWATCH_APP_ID, else APP_NAME). Two apps sharing one database and table prefix need different names (Laravel’s default APP_NAME is Laravel), or each would take the other’s tasks for its own tasks taken out.

Options per task

->cronwatch([...]) gives a task a job’s options, and ->cronwatch(false) leaves it out:

use Illuminate\Support\Facades\Schedule;

Schedule::command('reports:build')->dailyAt('02:00')
    ->cronwatch(['name' => 'nightly-report', 'grace' => '15m', 'timeout' => '30m', 'expect' => 'Report written']);

Schedule::command('orders:sync')->everyFifteenMinutes()->when(fn () => config('shop.sync'))
    ->cronwatch(['schedule' => '*/15 * * * *']);   // a filtered task, given the schedule it keeps

Schedule::command('cache:prune-stale-tags')->hourly()->cronwatch(false);

It takes name and the options of a job declared by hand: schedule, timezone, grace, timeout, maxDuration, budget, floor, expect, failuresBeforeAlert, description, and tags (see PHP). defaults in config/cronwatch.php applies grace, timeout, timezone, and failuresBeforeAlert to every job that sets none, and schedule.exclude lists job names to leave out. CRONWATCH_WATCH_SCHEDULE=false stops watching the schedule altogether; the check is still scheduled unless CRONWATCH_SCHEDULE_CHECK=false.

Queued jobs

A queued job is watched when it opts in, with the attribute (its options as named arguments) or the interface (CRONWATCH_WATCH_QUEUE=false stops watching queued jobs altogether):

use Cronwatch\Watch;

#[Watch(grace: '15m', failuresBeforeAlert: 3)]
final class SendNightlyReport implements ShouldQueue
{
    use Queueable;

    public function handle(): void
    {
        $sent = Report::sendAll();
        \Cronwatch\Cronwatch::current()?->log('Sent', $sent, 'reports');
    }
}

implements Cronwatch\Laravel\ShouldBeWatched does the same, with the options from a static cronwatch() method returning the options array when the class has one. #[Watch(enabled: false)] leaves a subclass out. The job is named after its class, backslashes as dots (App.Jobs.SendNightlyReport), unless name says otherwise.

Every attempt is a run of its own, recorded in the worker that ran it with the trigger "laravel-queue" ("queue" on runs recorded before 0.11), so the rules of every other port’s queues hold: failing attempts open one failed alert, the attempt that succeeds closes it with a recovery, and failuresBeforeAlert rides through retries. A job that hits its timeout is failed before the worker kills itself. A job released back onto the queue without an exception (a RateLimited or WithoutOverlapping middleware, or $this->release()) did not run, so its attempt is taken back: nothing is recorded or alerted, and the job’s count of failures in a row is left as it was. So is one a middleware skips without releasing it (->dontRelease() on those middleware, or Skip), which Laravel deletes unrun. A queued listener, mailable, or notification is watched by its own class. The sync queue records the same way.

A watched class that the scheduler dispatches (Schedule::job(new SendNightlyReport)->dailyAt('02:00')) is one job: the scheduler declares the schedule under the queued job’s name and options, and the worker records the runs, so a job that is dispatched but never handled is still reported missed.

Jobs of your own

Anything else (a command the system crontab runs, a script, a controller) can declare and run a job through the client, which the container holds as a singleton:

use Cronwatch\Cronwatch;
use Cronwatch\Job\JobContext;

app(Cronwatch::class)->job('partner-export', ['schedule' => '0 3 * * *', 'timeout' => '2h'])
    ->run(function (JobContext $job) {
        $job->log('Exported', PartnerExport::run(), 'rows');
    });

Declare such a job in a service provider’s boot() as well if it must be reported missed before its first run. A platform cron that calls a URL can call a route: handler() checks Authorization: Bearer <CRON_SECRET>, runs the job, and answers with how it went.

// routes/api.php (routes/web.php would put Laravel's CSRF check in the way)
use Cronwatch\Cronwatch;
use Cronwatch\Job\JobContext;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::post('/cron/nightly', app(Cronwatch::class)
    ->job('nightly-report', ['schedule' => '0 2 * * *'])
    ->handler(fn (JobContext $job, Request $request) => Report::build())
    ->laravel());

start() and resume() span a run across requests or jobs; see PHP and handler.

The check

A task that never ran records nothing, so something has to look. The provider schedules cronwatch:check every five minutes in the app’s own scheduler, which is already running every minute wherever scheduled tasks work, so there is no step to add. It declares the schedule, finds missed and stuck runs, sends their alerts, retries alerts a channel has not yet accepted, and prunes old runs. CRONWATCH_CHECK_CRON changes how often, and CRONWATCH_SCHEDULE_CHECK=false leaves it out, for a crontab line of your own:

*/5 * * * *  cd /var/www/app && php artisan cronwatch:check

It prints cronwatch: checked 3 jobs, sent 1 alert (nothing with --quiet); anything that goes wrong is one line on standard error and exit status 1. The check itself is never a job. Run one checker per store.

If the scheduler itself stops (its crontab line is gone, the server is down), the check stops with it, and nothing inside the app can say so. Pair it with an uptime monitor for that case (see Limits).

The store

CRONWATCH_STORE picks it:

  • database, the default: the app’s database (MySQL 8.0.13 or newer, MariaDB 10.6 or newer, Postgres, or SQLite), the default connection or CRONWATCH_DB_CONNECTION. CronWatch reads that connection’s settings as Laravel resolved them (a DB_URL, the write side of a read and write split, SSL options, Postgres’s sslmode and search_path) and opens a connection of its own with them, so its writes never join a transaction the app has open: a run recorded inside DB::transaction() stays recorded when the transaction rolls back. The tables are CronWatch’s names (CRONWATCH_TABLE_PREFIX, default cronwatch_); the connection’s own table prefix does not apply, so every port reads the same tables. SQL Server is not supported.
  • sqlite: a file of its own, storage/cronwatch/cronwatch.db unless CRONWATCH_SQLITE_PATH says otherwise.
  • memory: forgets when the process ends; for tests.

php artisan migrate runs the package’s migration (CRONWATCH_MIGRATIONS=false leaves it out). Each process also runs CREATE TABLE IF NOT EXISTS on first use, as every store does; where the app’s database user may not create tables, set CRONWATCH_CREATE_TABLES=false and let the migration make them.

Alerts

Variable
CRONWATCH_MAIL_TO one address or several, separated by commas, sent through the app’s mailer with the library’s subject, text, and HTML
CRONWATCH_MAIL_FROM, CRONWATCH_MAILER, CRONWATCH_MAIL_SUBJECT_PREFIX the sender (default the mailer’s mail.from), a mailer from config/mail.php, and a subject prefix
CRONWATCH_SLACK_WEBHOOK_URL a Slack incoming webhook
CRONWATCH_DISCORD_WEBHOOK_URL a Discord webhook
CRONWATCH_WEBHOOK_URL, CRONWATCH_WEBHOOK_SECRET a signed JSON webhook
CRONWATCH_LOG_CHANNEL a channel from config/logging.php, written to as well
CRONWATCH_TRIAGE=true Claude triage, reading ANTHROPIC_API_KEY; CRONWATCH_TRIAGE_MODEL and CRONWATCH_TRIAGE_CONTEXT

Email, Slack, and Discord alerts link to the job’s page on the dashboard, at APP_URL (or the dashboard’s CRONWATCH_DOMAIN), never at the host of whatever request sent the alert. alerts.channels in config/cronwatch.php takes more: any of the library’s channels (Resend, Postmark, SES, Twilio, Sentry, and the rest; see PHP), or class names the container makes. CRON_SECRET, CRONWATCH_RETENTION (default 30d), and CRONWATCH_DELIVER (check for a process that cannot reach the network) are read too. Failures outside jobs (the store, a channel) go to the app’s log.

To build the client yourself, bind Cronwatch\Cronwatch again in one of your own providers' register(), which runs after the package’s.

The dashboard

The dashboard and JSON API are at /cronwatch (CRONWATCH_PATH, or CRONWATCH_DOMAIN for a domain of its own): the jobs' health, the last 24 hours as a lane per job, each job’s week, runs, and output, and the endpoints the MCP server uses. They are the SDK’s pages and API; see Dashboard and API.

Who may open it is the viewCronwatch gate, asked with the signed-in user. Until you define it, it lets anyone in the local environment in, and no one elsewhere, as Horizon’s and Telescope’s gates do. Define it in a service provider’s boot():

use Illuminate\Support\Facades\Gate;

Gate::define('viewCronwatch', fn (User $user) => $user->is_admin);

A request the gate lets in is served with the app’s sign-in standing for the dashboard’s token. A request carrying a bearer token skips the gate and must carry CRONWATCH_TOKEN instead (or, for /cronwatch/api/check, CRON_SECRET), which is how the MCP server and a platform cron reach it:

claude mcp add cronwatch -e CRONWATCH_URL=https://app.example.com/cronwatch -e CRONWATCH_TOKEN=... -- npx -y @cronwatch/mcp

Laravel’s CSRF middleware is taken off the dashboard’s routes, since its forms carry no Laravel token and the routes refuse a cross-site write themselves; the origin they compare with is the request’s as Laravel sees it, so the app’s trusted proxies apply. CRONWATCH_DASHBOARD=false leaves the routes out.

Settings

Every key of config/cronwatch.php (publish it with php artisan vendor:publish --tag=cronwatch-config), the variable it reads, and the Symfony bundle's key for the same setting, which keeps the same spelling wherever the two mean the same thing:

config/cronwatch.php Variable Symfony cronwatch.yaml
enabled CRONWATCH_ENABLED
app_id CRONWATCH_APP_ID app_id
store.driver, store.connection, store.path CRONWATCH_STORE, CRONWATCH_DB_CONNECTION, CRONWATCH_SQLITE_PATH store (a URL), store_service
store.migrations CRONWATCH_MIGRATIONS
table_prefix CRONWATCH_TABLE_PREFIX table_prefix
create_tables CRONWATCH_CREATE_TABLES create_tables
alerts.mail.to, .from, .mailer, .subject_prefix CRONWATCH_MAIL_TO, CRONWATCH_MAIL_FROM, CRONWATCH_MAILER, CRONWATCH_MAIL_SUBJECT_PREFIX alerts.mailer.to, .from, .subject_prefix
alerts.slack, alerts.discord CRONWATCH_SLACK_WEBHOOK_URL, CRONWATCH_DISCORD_WEBHOOK_URL alerts.slack, alerts.discord
alerts.webhook.url, .secret CRONWATCH_WEBHOOK_URL, CRONWATCH_WEBHOOK_SECRET alerts.webhook.url, .secret
alerts.log (a log channel’s name) CRONWATCH_LOG_CHANNEL alerts.log (true or false: the logger has no channels to name)
alerts.channels (class names) alerts.services (service ids)
triage.enabled, .model, .context CRONWATCH_TRIAGE, CRONWATCH_TRIAGE_MODEL, CRONWATCH_TRIAGE_CONTEXT triage.enabled, .model, .context
cron_secret CRON_SECRET cron_secret
retention, defaults, deliver CRONWATCH_RETENTION, CRONWATCH_DELIVER retention, defaults, deliver
schedule.watch, schedule.exclude CRONWATCH_WATCH_SCHEDULE scheduler.watch, scheduler.exclude (and scheduler.jobs)
schedule.capture_output CRONWATCH_CAPTURE_OUTPUT
check.schedule CRONWATCH_SCHEDULE_CHECK check.schedule (a schedule’s name, or false)
check.frequency (a cron expression) CRONWATCH_CHECK_CRON check.frequency (a period, or a cron expression)
queue.watch CRONWATCH_WATCH_QUEUE messenger.watch
dashboard.enabled, .path, .domain CRONWATCH_DASHBOARD, CRONWATCH_PATH, CRONWATCH_DOMAIN the routes' import and prefix
dashboard.middleware dashboard.role
dashboard.token CRONWATCH_TOKEN dashboard.token

dashboard.token and cron_secret are read only as Laravel reads them. A value in the config is used as written, and an empty one, or one of only spaces, means none. When the config’s value is null, the variable is read through env(), so CRONWATCH_TOKEN=null (or (null), empty, true) in .env means no token, never the word. cron_secret set to false turns the secret off.

Releases before 0.11 spelled four of these differently: store.prefix (now table_prefix), store.create_tables (now create_tables), schedule.check (now check.schedule), and schedule.check_cron (now check.frequency). The variables did not change, so a config file that was never published needs nothing. A published one that still has an old key keeps working through 1.x: the old key is read in place of the new one, with a deprecation notice (Laravel writes those to its deprecations log channel when one is set) naming the key to rename it to. The old keys go in 2.0.

Tests

In tests, keep runs in memory, or turn CronWatch off:

<!-- phpunit.xml -->
<env name="CRONWATCH_STORE" value="memory"/>
<env name="CRONWATCH_ENABLED" value="false"/>

CRONWATCH_ENABLED=false watches nothing, schedules no check, and mounts no dashboard; app(Cronwatch::class) still works, so code that runs jobs by hand runs the same. The testing environment is not production, so the in-memory store does not warn; it is not development either, so a test that calls a job handler or the dashboard needs CRON_SECRET or a token. With the memory store, a test can run the schedule with $this->artisan('schedule:run') and assert on app(Cronwatch::class)->runs('prune-orders').