Skip to content

About

Read-only split of packages/php from cronwatchdev/cronwatch, for Packagist (cronwatch/cronwatch). Open issues and pull requests on the main repository.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

cronwatch/cronwatch

Cron and scheduled-job monitoring that lives inside your PHP app. Wrap a job once; every run is recorded in a database you already have, and you are told when a run is missed, fails, gets stuck, runs slow, goes over budget, or falls short. No server to run, no account to make.

This is the PHP port of @cronwatch/sdk: the same rules, the same alert text, the same requests to every alert channel, and the same stored rows, so a PHP process can share one database with a Node, Ruby, Python, Go, Rust, Elixir, Java, or .NET process, and every port reads the tables the others write. It has the core, the stores (memory, SQLite, MySQL, MariaDB, and Postgres), every alert channel, Claude triage, the pg_cron source, a vendor/bin/cronwatch check command, the dashboard and JSON API, a job handler for crons that call a URL, the Laravel and Symfony integrations, a WordPress plugin, a Drupal module, and a Craft CMS plugin (DESIGN.md has how each works).

Docs: cronwatch.dev

Install

PHP 8.2 or newer, and no dependencies beyond ext-json and ext-pcre: cron expressions are read by a port of croner (the parser the SDK uses) and zones come from PHP's own database. The SQLite store needs pdo_sqlite, the MySQL store pdo_mysql, and the Postgres store and the pg_cron source pdo_pgsql. The alert channels and triage use ext-curl when it is loaded and PHP's own streams otherwise.

Use

use Cronwatch\Alert;
use Cronwatch\Cronwatch;
use Cronwatch\Job\JobContext;
use Cronwatch\Store\SqliteStore;

$cw = new Cronwatch(
    store: new SqliteStore('/var/lib/app/cronwatch.db'),
    alerts: [fn (Alert $alert) => page($alert->title, $alert->message)],
);

$nightly = $cw->job('nightly-report', [
    'schedule' => '0 2 * * *', 'timezone' => 'UTC', 'grace' => '15m', 'timeout' => '30m',
    'expect' => 'Report written', 'budget' => ['cost' => 2],
]);

$nightly->run(function (JobContext $job) {
    $path = build_report();
    $job->log('Report written:', $path);   // kept with the run, shown in alerts
    $job->metric('cost', 1.2);             // watched against budgets and baselines
});

A run is recorded when the function returns; what it throws is recorded as the failure and thrown again, and a run cut short by exit() or a fatal error is recorded as failed when the process ends. run() returns what the function returns. $nightly->monitor($callable) gives a callable whose every call is a run, with Cronwatch::current() its context.

A PHP process does not stay up between runs, so missed and stuck runs are found by a check, every five minutes, from a second crontab line or the framework's scheduler. Put the client in a file that returns it, cronwatch.php:

<?php
require __DIR__ . '/vendor/autoload.php';

$cw = new Cronwatch\Cronwatch(store: new Cronwatch\Store\SqliteStore('/var/lib/app/cronwatch.db'), alerts: [/* ... */]);
$cw->job('nightly-report', ['schedule' => '0 2 * * *', 'timezone' => 'UTC']);
return $cw;

and the crontab line is vendor/bin/cronwatch check, which prints cronwatch: checked 1 job, sent 0 alerts (and exits 1 with the reason on standard error when something is wrong):

*/5 * * * * cd /var/www/app && vendor/bin/cronwatch check

It looks for cronwatch.php, then config/cronwatch.php, in the working directory; --bootstrap <file> or CRONWATCH_BOOTSTRAP names another. The app's own scripts can require the same file.

A run that starts in one call and ends in another (a job that hands work to a queue, a webhook that reports back later) is one run too:

$run = $nightly->start(id: $batchId);   // records a running run; a second start with this id finds it
// later, perhaps in another process
$run = $nightly->resume($batchId);
$run->log('sent 40 emails');
$run->finish();                         // or $run->fail($error), or $run->finish('text')

A run that is never finished is marked stuck by the first check after the job's timeout.

Options

job($name, [...]): schedule (five or six field cron, a nickname such as @hourly, or every 5m), timezone (IANA; default PHP's), grace (default 10m), timeout (default 1h), maxDuration, budget (['metric' => ceiling]), floor (['metric' => floor]: a run reporting less alerts), expect (a string the output must contain, a Cronwatch\Pattern it must match, or a callable), failuresBeforeAlert (default 1), description, tags. Durations are strings like 1h30m, milliseconds, or a DateInterval.

new Cronwatch(...): store, alerts (channels or callables; default the console), triage (a callable returning a short diagnosis added to each alert), sources, cronSecret (default CRON_SECRET; null for none on purpose), retention (default 30d), defaults, redact (secrets are blanked from output and errors by default; pass your own callable, or false), deliver (check queues alerts for another process's check to send), onError (store and channel failures; default PHP's error log), now.

check(), jobs(), jobsWithRuns(), jobSummary($name), runs($name), getRun($id), silence($name, '2h'), unsilence($name), forget($name), recordRun($run), resumeRun($name, $id), close().

Public means documented here or on the PHP page of the docs; every other class and method is marked @internal.

Changed in 0.11, and deprecated

cronSecret, the dashboard's token, and a handler's secret read null as every other language does: off. Before 0.11 null was the default and read the environment, and false turned them off. Leaving the argument out, or passing Cronwatch\FromEnv::Read, still reads CRON_SECRET or CRONWATCH_TOKEN.

Deprecated, working through 1.x and gone in 2.0: false for those three (use null) and $job->wrap($fn) (use $job->monitor($fn)). Deprecated and to be removed in 1.0, since they were public by accident: Alerts\Webhook::hmacSha256Hex() (use Webhook::signature()) and the Twilio, Sentry, and Discord channels' and the pg_cron source's static helpers and constants.

Stores

  • Cronwatch\Store\MemoryStore, the default: forgets when the process ends.
  • Cronwatch\Store\SqliteStore($path, prefix: 'cronwatch_'): one file, WAL mode, made private (0600), so every process that uses it (the web server, the crontab line, workers) must run as the same user. The tables, statements, and JSON are the SDK's SQLite store's, byte for byte, so a Node process using @cronwatch/sdk/sqlite on the same file sees the same jobs, runs, and state.
  • Cronwatch\Store\MysqlStore($url, prefix: 'cronwatch_'): MySQL 8.0.13 or newer, or MariaDB 10.6 or newer, from a mysql:// URL, a PDO DSN, the app's PDO, or DATABASE_URL. The same tables in MySQL's dialect (see DESIGN.md), with the SDK's JSON kept byte for byte. Given a URL or DSN, it writes through a connection of its own, so a run recorded inside the app's transaction survives a rollback.
  • Cronwatch\Store\PostgresStore($url, prefix: 'cronwatch_'): Postgres from a postgres:// URL, a PDO DSN, the app's PDO, or DATABASE_URL. The SDK's tables and statements, so a process in any other language can share the database. Given a URL or DSN, it writes through a connection of its own, so a run recorded inside the app's transaction survives a rollback.

Alert channels

use Cronwatch\Alerts;

$link = fn (\Cronwatch\Alert $a) => 'https://app.example.com/cronwatch/jobs/' . rawurlencode($a->job);
$cw = new Cronwatch(store: $store, alerts: [
    new Alerts\Slack(webhookUrl: getenv('SLACK_WEBHOOK_URL'), link: $link),
    new Alerts\Resend(apiKey: getenv('RESEND_API_KEY'), from: 'CronWatch <alerts@example.com>', to: ['ops@example.com']),
    new Alerts\Twilio(accountSid: getenv('TWILIO_ACCOUNT_SID'), authToken: getenv('TWILIO_AUTH_TOKEN'), from: '+15005550006', to: '+15551110000'),
]);

Slack, Discord, Webhook (signed with HMAC-SHA256 when given a secret), email through Resend, Postmark, Sendgrid, Mailgun, or Ses (Amazon SES, signed with SigV4, no AWS SDK needed), SMS through Twilio, and the trackers Sentry, Honeybadger, Datadog, Rollbar, Bugsnag, and NewRelic. Their options are the SDK's, as named arguments. Each request has a ten second deadline, follows no redirect, and never puts a credential in an error message. A channel is also any callable taking the Alert.

Claude triage

$cw = new Cronwatch(store: $store, alerts: $channels, triage: new \Cronwatch\Triage\Anthropic(context: 'A Laravel app on Forge.'));

Each alert but a recovery gets a short diagnosis from Claude: the likely cause and the first thing to check. It reads ANTHROPIC_API_KEY, needs no Anthropic package (it sends the Messages API request itself), and never holds an alert for long: when the answer is late or the request fails, the alert goes out without one.

pg_cron

$cw = new Cronwatch(store: new PostgresStore($url), sources: [new \Cronwatch\Sources\PgCron($url, prefix: 'db:')]);

Jobs pg_cron runs inside Postgres, where nothing can wrap them, are watched too: each check declares them from cron.job and copies their runs from cron.job_run_details.

Dashboard and JSON API

The SDK's dashboard, page for page and byte for byte: the jobs' health, the last day as a timeline, each job's week, runs, and output, silence, forget, and "Run check now", and the small JSON API that @cronwatch/mcp talks to. It needs no framework. In a bare script:

<?php
// public/cronwatch.php: open /cronwatch.php/ (or rewrite /cronwatch/ to this file)
$cw = require __DIR__ . '/../cronwatch.php';
$cw->routes()->serve();

serve() reads the request from PHP's superglobals and writes the answer with header() and echo. handle(Cronwatch\Web\Request): Cronwatch\Web\Response is the same routes for anything else, and with psr/http-server-handler installed (and psr/http-server-middleware for the middleware), plus a PSR-7 and PSR-17 implementation such as nyholm/psr7, Cronwatch\Web\PsrHandler (a PSR-15 request handler) and Cronwatch\Web\PsrMiddleware (answers under its base path, passes the rest on) take a PSR-17 factory:

$factory = new Nyholm\Psr7\Factory\Psr17Factory();
$app->add(new Cronwatch\Web\PsrMiddleware($cw->routes(), $factory, $factory));   // Slim, Mezzio, any PSR-15 stack

routes(token:, basePath:, origin:, trustProxy:):

  • token: everything needs it, as Authorization: Bearer <token>, or open the dashboard once with ?token=<token> and a cookie keeps the browser signed in. Defaults to CRONWATCH_TOKEN (leave it out, or pass Cronwatch\FromEnv::Read); a value is trimmed, given or read, and an empty value or one of only whitespace counts as unset. With none, the dashboard answers 503, except in development (CRONWATCH_ENV, APP_ENV, or WP_ENVIRONMENT_TYPE set to development, dev, or local; test and testing are not development, so a test run or CI needs a token, as production does), where it makes one, keeps it in a file in the system's temporary directory so every request asks for the same one, and writes a sign-in link to the server log (naming the host only when origin is set or the request's host is loopback, since a client chooses it). null serves it open, for behind your own auth (before 0.11 null read CRONWATCH_TOKEN and false served it open; false still does, deprecated). /api/check also takes the client's cronSecret, for a platform cron.
  • basePath: where it is mounted. Default: the script, for a path-info URL like /cronwatch.php/, else /cronwatch.
  • origin (https://app.example.com) or trustProxy: true: the public origin, for an app behind a proxy, used for the same-origin check on changes, the cookie's Secure flag, and the redirects.

Changes (silence, forget, the check) are refused from another site, the pages carry a strict CSP and load nothing but their own app shell, and the dashboard installs as an app (a manifest, icons, and a service worker that caches only the shell).

A job behind a URL

For a cron that calls a URL (a platform's cron, Cloud Scheduler, a crontab line running curl), handler() makes a request handler that runs the job, the SDK's handler():

$cron = $cw->job('nightly-report', ['schedule' => '0 2 * * *'])
    ->handler(fn (JobContext $job, $request) => build_report($job));

$cron->serve();                                              // public/cron/nightly.php, from the superglobals
Route::post('/cron/nightly', $cron->laravel());              // Laravel (routes/api.php, which has no CSRF check)
return $cron($request);                                      // a Symfony (or Laravel) controller
$psr = new Cronwatch\Web\PsrJobHandler($cron, $f, $f);       // PSR-15, with a PSR-17 factory

The caller must send Authorization: Bearer <secret>: the handler's secret:, else the client's cronSecret (CRON_SECRET by default). With no secret at all it answers 503 outside development; secret: null lets anyone run the job (false, deprecated, does the same; before 0.11 null meant the client's secret). Each request is a recorded run, answered with {"ok","job","run","status","durationMs"} and 200 or 500. A function that returns a response is answered with it, and a status of 400 or more fails the run, from any job: a PSR-7 response, Symfony's or Laravel's, Laravel's HTTP client response, a Cronwatch\Web\Response, or an array such as ['status' => 503, 'body' => 'down'].

Laravel

composer require cronwatch/cronwatch and php artisan migrate (Laravel 12 and 13): every task in the schedule is watched with no code changes, queued jobs opt in, and cronwatch:check is scheduled for you. See Laravel.

Symfony

Register Cronwatch\Symfony\CronwatchBundle (Symfony 6.4, 7.4 and 8.1): every Scheduler message is watched, Messenger messages opt in, and the check joins your default schedule. See Symfony.

WordPress

The CronWatch plugin (wordpress/) watches every WP-Cron event with no code, with alerts by email, Slack, a webhook, or any of the library's other channels, and the dashboard in wp-admin. See WordPress.

Drupal

The Drupal module (drupal/, drupal/cronwatch on drupal.org; Drupal 10.3 and newer and 11) records every cron run and each module's hook_cron, with the dashboard under Reports. See Drupal and drupal/README.md.

Craft CMS

The Craft CMS plugin (craft/, cronwatch/craft; Craft CMS 5.3 and newer) watches the console commands a crontab runs and the queue jobs you choose, with the dashboard in the Control Panel. See Craft CMS and craft/README.md.

Testing

From this directory:

composer install
vendor/bin/phpunit

tests/ConformanceTest.php replays the cases in the repository's conformance/ directory, generated from the TypeScript SDK, and tests/ChannelConformanceTest.php, tests/TriageTest.php, and tests/PgCronTest.php replay the channel, triage, and pg_cron fixtures. The MySQL and MariaDB tests run when CRONWATCH_TEST_MYSQL and CRONWATCH_TEST_MARIADB are mysql:// URLs, the Postgres tests when CRONWATCH_TEST_PG is a postgres:// URL, and the pg_cron tests when CRONWATCH_TEST_PGCRON names a Postgres with pg_cron preloaded (shared_preload_libraries=pg_cron, and cron.database_name set to the URL's database). CI starts them all; locally, for example against throwaway servers:

docker run -d --rm --name cw-mysql -e MYSQL_ROOT_PASSWORD=pw -e MYSQL_DATABASE=cw -p 33061:3306 mysql:8
docker run -d --rm --name cw-mariadb -e MARIADB_ROOT_PASSWORD=pw -e MARIADB_DATABASE=cw -p 33062:3306 mariadb:10.11
docker run -d --rm --name cw-pg -e POSTGRES_PASSWORD=pw -e POSTGRES_DB=cw -p 55432:5432 postgres:16-alpine
CRONWATCH_TEST_MYSQL=mysql://root:pw@127.0.0.1:33061/cw CRONWATCH_TEST_MARIADB=mysql://root:pw@127.0.0.1:33062/cw \
  CRONWATCH_TEST_PG=postgres://postgres:pw@127.0.0.1:55432/cw vendor/bin/phpunit

tests/WebGoldenTest.php replays packages/ruby/test/web/golden.json, the SDK routes' answers to a fixed seed, through handle(), the superglobals, and PSR-7, and every status, header, and body must match; tests/WebServeTest.php runs serve() under php -S. The MCP server's end to end test drives the dashboard too: CRONWATCH_TEST_PHP=1 npm test --workspace packages/mcp at the root (after composer install here).

tests/ChannelsTest.php runs the default HTTP client (curl, and its own client on PHP's stream sockets) against a local php -S server and a raw socket server. The WordPress plugin's tests install WordPress with WP-CLI and run the built plugin in it, when CRONWATCH_TEST_WORDPRESS is a mysql:// URL and CRONWATCH_TEST_WPCLI the path to wp-cli.phar (CRONWATCH_TEST_WP_VERSION picks the WordPress version).

tests/FinishOnceTest.php starts PHP worker processes that finish the same runs at the same moment, on SQLite and on each server. tests/NodeCompatTest.php shares a SQLite file with the built SDK, and tests/ScheduleFuzzTest.php checks thousands of generated cron expressions against croner itself; both need Node and the SDK built first (npm ci && npm run build at the repository root), and skip with the reason otherwise. npm run check:php at the root runs the suite.

tests/Drupal/DrupalTest.php makes a Drupal project with Composer, installs a site with Drush, and drives the module in it, when CRONWATCH_TEST_DRUPAL is a drupal/core constraint (^11.4, ~10.6.0); CRONWATCH_TEST_DRUPAL_DB is a mysql:// or postgres:// URL for the site's database (default a SQLite file). tests/Craft/CraftTest.php does the same for Craft CMS when CRONWATCH_TEST_CRAFT is a craftcms/cms constraint (^5.3) and CRONWATCH_TEST_CRAFT_DB a mysql:// or postgres:// URL. Each needs Composer on the PATH (or CRONWATCH_TEST_COMPOSER), and keeps its project in the system's temporary directory between runs:

CRONWATCH_TEST_DRUPAL='^11.4' vendor/bin/phpunit tests/Drupal
CRONWATCH_TEST_CRAFT='^5.3' CRONWATCH_TEST_CRAFT_DB=mysql://root:pw@127.0.0.1:33061/cw vendor/bin/phpunit tests/Craft

tests/Laravel/ runs the Laravel integration on Orchestra Testbench and tests/Symfony/ the bundle on FrameworkBundle's test kernel; the dev dependencies bring the newest of each that the PHP allows (Laravel 12 and Symfony 7.4 on PHP 8.2, Laravel 13 and Symfony 8.1 on 8.4 and newer), and without them those tests are skipped. To test another series, pin it and take the other framework out, as CI's php-frameworks job does:

composer remove --dev --no-update orchestra/testbench
composer require --dev --no-update "symfony/framework-bundle:6.4.*" "symfony/scheduler:6.4.*" "symfony/messenger:6.4.*" \
  "symfony/security-bundle:6.4.*" "symfony/browser-kit:6.4.*" "symfony/console:6.4.*"
composer update && vendor/bin/phpunit tests/Symfony

The Laravel store tests use CRONWATCH_TEST_MYSQL, CRONWATCH_TEST_MARIADB, and CRONWATCH_TEST_PG too.

License

MIT

About

Read-only split of packages/php from cronwatchdev/cronwatch, for Packagist (cronwatch/cronwatch). Open issues and pull requests on the main repository.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages