packages/php is cronwatch/cronwatch on Packagist (namespace Cronwatch\): the same library as @cronwatch/sdk, for PHP apps, from a plain crontab script to Laravel, Symfony, WordPress, Drupal, and Craft. It is a port, not a new design, made the way the Ruby gem and the Python package were (see their DESIGN.md). The TypeScript SDK is the source of truth for every behaviour, message, and stored byte; when the two disagree, the PHP side is wrong.
- PHP 8.2 or newer, tested on 8.2 and 8.5 in CI (and 8.3 by hand). 8.1 left security support at the end of 2025, and 8.2 is the floor of everything the later phases plug into: Laravel 12, Symfony 7, Craft 5, and PHPUnit 11 all require it, and Laravel 13, Symfony 8, and Drupal 11 need newer (8.3, 8.4, and 8.3). WordPress still runs on older PHP, but a plugin declares
Requires PHP: 8.2and WordPress refuses to activate it on anything older, with a message, rather than failing at runtime. The code itself leans on 8.1 (readonly properties,never, first-class callables,array_is_list); nothing here would be simpler on 8.3, so the floor is set by support and by the frameworks, not by syntax. - The core has no runtime dependencies beyond
ext-jsonandext-pcre, which every PHP has. Cron parsing and fire times are a port of croner 10 (src/Cron/, as the Python port's_cron.py), zones come from PHP's own zone database, and nombstringorintlis needed: UTF-16 lengths, cuts, and scrubbing are done on the bytes. The stores needpdo_sqlite,pdo_mysql, orpdo_pgsql, named insuggestand checked with a clearLogicExceptionwhen missing. The channels and triage useext-curlwhen it is loaded and PHP's ownhttp://streams otherwise, so neither is required; no Composer package is either (triage speaks to the Messages API itself rather than through Anthropic's PHP SDK). An optional piece is its own class, and needs only what it uses. - Times are epoch milliseconds (
int) everywhere, as in the SDK, soEvaluateports line for line and stored rows are identical. PHP'sintis 64 bits, so no time loses precision. A time before the year 1 or after 9999 (a start from a foreign or damaged row) is never written as a date:Js::isoTime()answers null for it andJs::beyondDates()the SDK's words ("before 0001-01-01 00:00:00 UTC", "after 9999-12-31 23:59:59 UTC"), which alert text, triage prompts, and the dashboard write in its place, with no relative part; a tracker'sstartedAtis then null. - Names are the SDK's, since PHP is camelCase too:
failuresBeforeAlert,maxDuration,startedAt,recordRun(),jobsWithRuns(). Job options are an array with the SDK's keys, in the order given (['schedule' => '0 2 * * *', 'grace' => '15m']), so the stored definition has the SDK's key order; an unknown key is refused. The client takes named arguments (new Cronwatch(store: ..., alerts: ...)). Run statuses, conditions, alert types, and health are the wire strings, with constants for them (RunStatus::OK,Condition::OVER_BUDGET,JobHealth::NEVER_RAN); PHP enums were weighed and left out, since every value that crosses a store or a network is a string and a newer writer's value must pass through unharmed. The same holds for a job's state:JobStatekeeps the fields it does not know as they were read and writes them back after its own, so a process of this release never erases what a newer one wrote (conformance/client.json,unknownFields). So does an alert held in the state (undelivered, and the alert of asendingentry): its unknown fields are written back after its own and sent with it when it is retried, and its details' values stay as decoded, so an empty object in them stays{}. Asendingentry read from the state is written back exactly as stored, malformed or with keys this release does not know, until a check releases it (QueuedAlertsTest). - JSON is written by
Js::stringify, which isJSON.stringifybyte for byte: numbers as JavaScript prints them (2not2.0,1e-7,1e+21), array-index keys first in ascending order and the rest as inserted, only control characters, quotes, and backslashes escaped (so/,é, and U+2028 are written as themselves). A PHP array that is a list is a JSON array, anything else an object; the types write their maps (metrics, open conditions, details, budgets, floors) as objects, so an empty one is{}.json_encodediffers fromJSON.stringifyon each of these points, and is used nowhere a byte counts. - Alert titles and messages are the SDK's text, character for character.
JobDefinitionkeeps its fields as given, in order (defaults, then options as given, thenname, and a storedexpectlast), including fields a newer writer added. - Text entering a run (logged output, a returned string, an error's message) is made valid UTF-8 first: bytes that are not UTF-8 become U+FFFD, one per maximal subpart, as a JavaScript string decoded from them holds it. Lengths and cuts are in UTF-16 code units, as JavaScript counts them (
Js::length16,head16,tail16); a cut through a surrogate pair leaves U+FFFD where JavaScript keeps a lone surrogate, which is what that surrogate becomes once written out as UTF-8, so the stored bytes are the same. - Secret redaction uses the SDK's patterns in PCRE's UTF mode. PHP's
/ualso turns on Unicode\s,\b, and case folding, so JavaScript's whitespace class is spelled out,\bis spelled with ASCII word characters, and case-insensitive words are spelled[Ss][Ee]...(ASCII-only folding, as JavaScript's/ihas it here). Text with characters outside the Basic Multilingual Plane is matched with each one written as two placeholder characters, one per UTF-16 surrogate, so bounded quantifiers count as JavaScript counts, then put back together. The PEM pattern's body ((?:...|-(?!----)){0,16384}) is measured in code rather than by PCRE, which writes a bounded group out once per repeat and cannot compile sixteen thousand copies; the result is the same, since the footer is optional and JavaScript never backtracks into the body.conformance/output.json's 204 redaction cases pass. - Every condition opens once and closes with a recovery. No repeat alerts. A job whose schedule is removed while missed is open has missed closed by the next check with a recovery of its own (
reason: "unscheduled"). - A job's function may throw anything (
ExceptionorError): it is recorded as the failure and thrown again. A run still in progress when its process ends (exit()inside the job, or a fatal error such as memory running out, which nocatchsees) is recorded by the client's shutdown hook as a failed run,Interrupted: the process exited during the runorInterrupted: Fatal error: <message>with the file and line, so no run is left running to be reported stuck later. A process killed outright (SIGKILL, the OOM killer) records nothing, and its run is marked stuck after the job's timeout, as in every port. - An error is written
Name: messageand up to five frames, innermost first, eachat Class->method (file:line), as a JavaScript stack reads. The name is the class's own name without its namespace (PDOException,ImportFailed), as a JavaScript error's name has none. - The environment is read in one place,
Env(the SDK readsNODE_ENV; the Python port's_env.pyhas the same shape): the first ofCRONWATCH_ENV,APP_ENV(Laravel, Symfony, Craft), andWP_ENVIRONMENT_TYPE(WordPress) whose value, trimmed, is not empty (a value of only spaces counts as unset), lowercased, fromgetenv(),$_ENV, or$_SERVER, since frameworks that read a.envfile put its values in one of those; failing those, what a framework integration names withEnv::setFallback()(the WordPress plugin giveswp_get_environment_type(), which also reads the constant; Laravel's gives the app's environment, Symfony's the kernel's).development,dev, andlocalare development (Symfony'sdevand Laravel'slocalincluded),productionandprodproduction, and anything else (test,testing,staging) neither, so a test run or CI needsCRON_SECRETor a token, as production does (Env::environmentOf(), which every port reads the same way, replayed fromconformance/client.json). It decides whether the in-memory store warns that it forgets, and whether the dashboard makes a development token when none is configured. - No em or en dashes anywhere, as in the rest of the repo.
packages/php/
composer.json phpunit.xml.dist README.md LICENSE DESIGN.md
src/
Cronwatch.php the client: job, run, check, silence, forget, jobs, runs, recordRun, resumeRun, VERSION
Js.php JavaScript's numbers, JSON.stringify, trim, \s, UTF-16 lengths, UTF-8 scrubbing, Date.UTC, toISOString
Env.php the environment
FromEnv.php FromEnv::Read, the default of cronSecret and the dashboard's token: read the environment
Duration.php "15m", "1h30m", DateInterval: parse and format
Stats.php percentiles
Output.php the output cap, error messages, secret redaction
Schedule.php parse, nextFire, expectation, runCovers, firesBetween (schedule.ts)
ParsedSchedule.php Expectation.php
Cron/ croner: CronPattern (the reading of an expression, its checks, and messages), CronDate (the walk),
Cron (nextRuns), Zone (zones, croner's wall-clock arithmetic, fromTZ), CronError
Evaluate.php the alert rules, pure functions (evaluate.ts)
Evaluation.php CheckEvaluation.php
Format.php alert titles and messages
Serialize.php stored definitions, expect rules
Pattern.php a regular expression for expect, stored as "/source/flags"
Run.php JobState.php JobDefinition.php StoredJob.php Alert.php AlertDraft.php JobSummary.php CheckResult.php
JobWithRuns.php RunStatus.php Condition.php AlertType.php JobHealth.php TriageContext.php Source.php
SendingAlert.php
Job/ JobHandle (run, wrap, start, resume, handler), JobContext (log, metric, aborted), RunRecorder
(and LineWindow, the lines it keeps, dropped from the front without shifting the array),
RunHandle (a run started by start() or found by resume()), AbortSignal, AbortError, RetryFinish,
Handler (handler(): the bearer secret, the JSON answer, its adapters), Outcome
Watch.php #[Cronwatch\Watch]: a queued job's or a message's job options (Laravel, Symfony,
Drupal's queue workers, Craft's queue jobs)
Bridge/ what the framework integrations share: JobName (names from commands and classes),
Unscheduled (jobs of tasks taken out of a schedule, declared again without one),
EmbeddedDashboard (the dashboard inside a CMS's admin, its path in a query parameter),
TestAlert (a settings page's test alert), ChannelSettings (the channels a CMS
settings page offers beyond its own email, Slack, and webhook)
Laravel/ the Laravel integration (see Frameworks): CronwatchServiceProvider, ClientFactory,
DatabaseStore, ScheduledTasks, ScheduleWatcher, EventOptions, QueueWatcher, ShouldBeWatched,
CheckCommand, LogChannel, MailChannel, Http/ (DashboardController, Authorize),
config/cronwatch.php, migrations/
Symfony/ the Symfony bundle (see Frameworks): CronwatchBundle, ClientFactory, Stores, ScheduledMessages,
SchedulerSubscriber, MessengerSubscriber, CheckCommand, CheckMessage, CheckMessageHandler,
CheckSchedule, CheckSchedulePass, DashboardController, LoggerChannel, MailerChannel, config/routes.php
Alerts/ AlertChannel (the interface), ChannelContext, Console, Custom; Http (the interface), HttpResponse,
NativeHttp (curl, or its own client on stream sockets), Transport (the Http a channel given none uses), RequestTimeout;
Shared (shared.ts), Email (email.ts), SigV4;
Slack, Discord, Webhook, Resend, Postmark, Sendgrid, Mailgun, Ses, Twilio, Sentry,
Honeybadger, Datadog, Rollbar, Bugsnag, NewRelic
Triage/Anthropic.php Claude triage over the Messages API (triage/anthropic.ts)
Sources/ PgCron (sources/pgcron.ts), PgCronPdo (its queries through PDO)
Store/ Store (the interface), UpdatesRunIf, ComparesAndSetsState, and DeletesRunIf (the conditional writes),
MemoryStore, Sql (schema, statements, parameters, rows), PdoStore, SqliteStore, MysqlStore,
PostgresStore, Migrated (a store whose tables a migration made)
Web/ the dashboard and JSON API (routes/): Dashboard (index.ts: the routes, auth, CSRF, headers;
its development token's file in DevelopmentToken),
Request and Response (made from the superglobals, PSR-7, or by hand), Html (html.ts; its page head
in StandaloneHead, unless a host gives its own), Timeline
(timeline.ts), Pwa (pwa.ts), Icons (icons.ts, written by scripts/make-dashboard-icons.mjs),
Origin (new URL(value).origin), Text (escapeHtml, toFixed, encodeURIComponent),
PsrHandler and PsrMiddleware (PSR-15, the only files that need the PSR interfaces, with
PsrJobHandler, handler()'s), ResponseStatus (the HTTP responses a job may return),
HttpFoundation (Symfony's and Laravel's requests and responses, duck typed)
Cli.php vendor/bin/cronwatch
bin/cronwatch the Composer bin: `vendor/bin/cronwatch check`
wordpress/ the WordPress plugin (see Frameworks), built into its own zip
drupal/ the Drupal module (see Frameworks), a package of its own: drupal/cronwatch
craft/ the Craft plugin (see Frameworks), a package of its own: cronwatch/craft
tests/
ConformanceTest.php replays conformance/*.json that the core covers, the store scripts against every store
ChannelConformanceTest.php conformance/channels.json: every channel's requests byte for byte
ClientConformanceTest.php conformance/client.json: run ids, and stored fields a newer release wrote kept, on every store
TriageTest.php conformance/triage.json: the parameters, the HTTP request, the answers
PgCronTest.php conformance/pgcron.json, the SDK's pg_cron tests, and the real extension
ChannelsTest.php alerts.test.ts, channels-hardening.test.ts, and sigv4.test.ts, and NativeHttp (curl and
streams) against a local server (tests/servers/router.php under `php -S`)
CliTest.php vendor/bin/cronwatch
EnvTest.php development and production read as every port reads them
WebGoldenTest.php packages/ruby/test/web/golden.json: every page, header, and JSON body of the SDK's routes,
through handle(), the superglobals, and PSR-7
WebTest.php the SDK's routes tests (routes*.test.ts), and the superglobals, a path-info mount, the
development token's file and the PSR-15 middleware
WebServeTest.php serve() under php -S (tests/servers/web.php, which packages/mcp/test/php-web.test.ts
drives too): the headers PHP sends, HEAD, a multipart form PHP read itself
ClientTest.php ClientHardeningTest.php StartFinishTest.php CorrectnessTest.php ConcurrencyTest.php
the SDK's client tests
StoreConformanceTest.php store-conformance.ts, for memory, SQLite (in memory and on disk), MySQL, MariaDB and
Postgres
FinishOnceTest.php finish-once and concurrency with real processes (tests/workers/worker.php) on SQLite,
MySQL, MariaDB, and Postgres
SqliteStoreTest.php MysqlStoreTest.php PostgresStoreTest.php JsTest.php
ShutdownTest.php a run ended by exit() or a fatal error is recorded (tests/workers/interrupted.php)
HandlerTest.php the SDK's handler tests, responses as outcomes, PSR-15, and serve() under php -S
(tests/servers/handler.php)
BridgeTest.php job names, unscheduled jobs, #[Watch], the migrated store
Laravel/ the Laravel integration on Orchestra Testbench (see Frameworks)
Symfony/ the Symfony bundle on FrameworkBundle's test kernel (see Frameworks)
NodeCompatTest.php one SQLite file shared with the built SDK (tests/node/node_store.mjs)
ScheduleFuzzTest.php generated expressions answered by croner itself (tests/node/schedule_fuzz.mjs)
WordPress/ the plugin against a real WordPress (see Frameworks)
Drupal/ the module in a real Drupal, and its fixture module cwt_fixtures (see Frameworks)
Craft/ the plugin in a real Craft, and its fixture module cwt (see Frameworks)
Support/ the test clock, a capturing channel, a fake and a real HTTP server, stores that fail or
interleave on cue, backends
use Cronwatch\Cronwatch;
use Cronwatch\Job\JobContext;
use Cronwatch\Store\SqliteStore;
$cw = new Cronwatch(
store: new SqliteStore('/var/lib/app/cronwatch.db'), // default: a MemoryStore
alerts: [fn (Cronwatch\Alert $alert) => page($alert->title, $alert->message)], // default: Console
retention: '30d',
);
$nightly = $cw->job('nightly-report', [
'schedule' => '0 2 * * *', 'timezone' => 'UTC', 'grace' => '15m', 'timeout' => '30m',
'expect' => 'Report written', 'budget' => ['cost' => 2], 'failuresBeforeAlert' => 1,
]);
$nightly->run(function (JobContext $job) {
$job->log('Report written:', $path);
$job->metric('cost', 1.2);
});
$task = $nightly->monitor($callable); // a callable whose every call is a run; Cronwatch::current() is its context
$cw->check(); // from a crontab line, or the framework's scheduler, every few minutes
$cw->silence('nightly-report', '2h');run() returns what the function returns and throws what it throws, after the run is recorded; a returned string is the output when nothing was logged. $cw->run($name, $fn, $options) declares and runs. Durations are strings, milliseconds, or a DateInterval (stored as its milliseconds). expect is a string the output must contain, a Pattern (new Pattern('/wrote \d+ files/i')) it must match, or a callable; a plain string is always a substring, since PHP has no regular expression type to tell the two apart. defaults: takes grace, timeout, timezone, and failuresBeforeAlert. cronSecret: is the secret the dashboard's check endpoint takes in place of its token: the default, FromEnv::Read, reads CRON_SECRET, "" or a value of only whitespace (given or read) counts as unset, and null means none, as in the SDK; any other type (true, a number) throws a TypeError. A secret read from the environment (CRON_SECRET, CRONWATCH_TOKEN) also counts as unset when it is one of the words Laravel's env() reads as null, true, false, or empty (null, (null), true, (true), false, (false), empty, (empty), in any case), since Laravel hands its config null for CRON_SECRET=null while the variable keeps the word (Env::secret()). $cw->routes(token:, basePath:, origin:, trustProxy:) is the dashboard (see Web).
Public means documented in the README or on the PHP page of the docs (site/docs/php.md); every other class is marked @internal in its docblock, and so is every public method a documented class has only for the library's own use (the execution methods on Cronwatch the integrations call, Alert::setTriage()). PHP cannot hide a class from another namespace, so the marker is the promise: an @internal name may change in any release.
0.11 changed one meaning and deprecated two names, each kept working through 1.x with @deprecated and gone in 2.0:
nullforcronSecret:, the dashboard'stoken:and a handler'ssecret:turns it off, as in every other port; until 0.11 it was the default and read the environment, andfalseturned it off.falsestill does, deprecated. The default is nowCronwatch\FromEnv::Read(on a handler, the client's secret).JobHandle::wrap()ismonitor(), the name of the Python package's decorator.
Alerts\Webhook::hmacSha256Hex(), public by accident, is deprecated and goes in 1.0, not 2.0: use signature(), the name every port gives it (the Deprecations page and its docblock say the same).
$job->start(trigger:, id:), $job->resume($id), and $cw->resumeRun($name, $id) are the SDK's start(), resume(), and resumeRun(), and return a RunHandle: id, job, startedAt, isActive(), log(), metric(), metrics(), flush(), finish($outcome = null), and fail($error). An outcome is null or ['status' => 'ok'] (ok), ['error' => $e] or a Throwable on its own (failed, written like an error run() caught), or a string or ['result' => $x], treated like run()'s return value. The SDK's other outcome, an HTTP response of 400 or more, fails the run, from finish() as from run() (see Handler).
The client's side follows client.ts: a start with an id checks it (the SDK's messages, including the reserved pgcron: prefix) and finds a run already stored under it, so two starts with one id record one run and another job's start with that id throws. finish() reads the stored run again, joins the stored output with the handle's (capped), and merges metrics (the handle's win), then judges it with the same code as run(). flush() redacts the lines it appends and writes only over a row still running and of this job (updateRunIf); when it cannot, the handle keeps the lines for finish(), and it keeps the first 16 KB of what it flushed so expect at finish sees an early line. The store never throws out of start, resume, flush, or finish: failures go to onError as recording <job>, starting <job>, resuming <job>, flushing <job>, or finishing <job>. A store that fails during finish() records nothing and leaves the handle active, lines kept, so it can be called again. A finish that records nothing (... was already finished by this handle, ... was already finished as ok, ... was not found, ... belongs to job "<other>") is reported, never thrown, and returns null.
A run is judged once, however many processes finish it: the finish is written only over a stored row still running, else over one still timeout (a check already counted it as stuck: a late failure is written but not judged, a late success is judged and recovers), through the store's updateRunIf, one conditional UPDATE. Only the process whose write lands evaluates. The stuck check marks a run timed out the same way, so a finish that landed meanwhile wins. An evaluated recordRun() of a new run first writes it as a running placeholder, whose finishedAt is when it was written when the run itself has finished (null for a running import, which times out by the job's timeout), and finishes it once its pending marker is in the state; a placeholder whose finish never landed (the importer stopped, or a store write failed and was not retried) is closed by a check SEND_LEASE_MS after it was written (Evaluate::isStranded), as timeout with its own finishedAt and duration and the error Imported, but its finish was never written; marked as timed out (then the run's own, when it had one), so a late finish of it is judged as one after a timeout and a success still recovers. insertRun throws for an id already stored, the memory store included. FinishOnceTest holds this with real processes: two finishing one run, two recording one run from a source, six starting and finishing the same five ids, and three failing one job at once, on SQLite, MySQL, and MariaDB.
A PHP process runs one thing at a time, and usually lives for one request or one cron script, so the client has no threads, locks, or interval timer. A job runs in the caller. Every read-modify-write of a job's state goes through updateState: it reads the state, works out the next one, and writes it only when it changed, with version one higher, through the store's compareAndSetState against the version read; a refused write is worked out again from a fresh read, up to 10 times. A store without compareAndSetState gets setState, and one without compareAndSetState or updateRunIf is reported once per client, through onError as store, when it is first used (the SDK's message), since it is safe only while one process at a time uses it. This is what keeps processes sharing a store (PHP-FPM workers, queue workers, cron scripts) from losing each other's updates, and ConcurrencyTest makes the moment on purpose in one process while FinishOnceTest does it with several.
There is no start(): a PHP process does not stay up between checks unless it is a worker, so check() is called from a crontab line, the framework's scheduler, or a worker's loop. The later phases give each framework its way of doing that (see Frameworks). A check called from inside a check (a channel, a source, or triage calling check()) throws LogicException, where the SDK would wait on the check it is part of.
deliver: 'now' (the default) sends each alert from the process that produced it. deliver: 'check' sends nothing: the alert is queued in the job's state (undelivered, at most 20, the oldest dropped first and reported) for the next check in a process that delivers now, which triages and sends it, as the SDK's deliver: "check" does. An alert a channel refused is queued the same way and retried once per check, oldest first, to the channels that refused it and no others: the held copy carries owed, the keys of those channels (Evaluate::channelKeys(): a channel's name, then #2, #3 for a second and third of the same name), and a retry goes to the ones still configured (owedChannels()), shrinking the list as each takes it (afterSend()); a channel never sees owed. A held alert without owed (never sent, queued by deliver: 'check', or held by an older release) is owed by every channel configured now, and one whose owed channels are all gone is done. lastAlertAt moves when some channel took an alert on that attempt. One that no longer describes the job (Evaluate::staleAlert) is dropped; one check spends at most 20 seconds of wall clock retrying across all jobs. Every alert written into the state (sending and undelivered) is a trimmed copy (Evaluate::heldAlert()): its run's error keeps its first 2048 UTF-16 units and its output its last 2048, its message its first 4096, each with a line counting what was left out, never splitting a surrogate pair, and a text at most 64 units past its cap kept whole, so trimming again changes nothing. The first send uses the alert as composed, full run and all; only a retry sends the held copy. With deliver: 'check' the alert is queued in the same write that opens its condition. Triage is tried once per alert: Alert::$triage null with triageTried true is JSON null, never tried again.
Channels are sent to one after another, and triage is asked in turn, since PHP cannot run them at once or abandon one that hangs. Each gets its deadline instead: triage is handed an AbortSignal that aborts after 25 seconds (remainingMs() for the request's own timeout), and every channel request has a ten second deadline, as the SDK's fetch has. A diagnosis that arrives late is used rather than thrown away. An alert is written with the state that opens its condition, in the same write, into sending with a five minute lease (Evaluate::holdAlerts, SEND_LEASE_MS), and taken out once its send is recorded (Evaluate::recordSent); a check releases an entry whose lease ran out to the retry queue (Evaluate::releaseSending), so an alert whose process was cut short while sending is sent by a later check, once, or twice if a channel took it just before the process ended (OutboxTest ends real processes both ways). To send it now rather than five minutes on, outside the command line (a handler, the dashboard's check, wp-cron.php) the client calls ignore_user_abort(true) before sending and moves a max_execution_time on to at least two minutes (set_time_limit()), and HandlerTest has an alert outlast a one second limit under php -S.
A channel implements Alerts\AlertChannel (name() and send(Alert, ChannelContext), which throws when the alert went nowhere), or is any callable, which becomes a Custom channel named "custom"; one that takes two arguments also gets the ChannelContext, whose onError() reports a problem that did not stop the alert as alert channel <name>. Console is the default (standard error on the command line, PHP's error log elsewhere).
The SDK's channels are here, request for request, as the gem's and the Python package's are: Slack, Discord, and Webhook (HMAC-SHA256 of the body in X-CronWatch-Signature, and of <timestamp>.<body> in X-CronWatch-Timestamp-Signature beside X-CronWatch-Timestamp, unix seconds from the now: clock, so a receiver can refuse a replay; a header the app passes replaces the default of the same name in any case, and never a signature header), the email providers Resend, Postmark, Sendgrid, Mailgun, and Ses (signed with SigV4, checked against the AWS test suite, so no AWS SDK), Twilio for SMS, and the trackers Sentry, Honeybadger, Datadog, Rollbar, Bugsnag, and NewRelic. Their options are named arguments with the SDK's names (new Resend(apiKey: ..., from: ..., to: [...], subjectPrefix: '[prod]', link: fn (Alert $a) => ...)), so ChannelConformanceTest builds each straight from the fixture's options, and replays conformance/channels.json: every request's URL, headers, and body, byte for byte, for thirteen sample alerts and 29 option sets, the error each channel gives for a refused request, Twilio's partial delivery, and the text cuts. As in the SDK:
- One deadline, ten seconds, for the whole request (connecting, sending, and reading the answer): past it before an answer is
RequestTimeout("The operation was aborted due to timeout"), past it while the body arrives is the answer with an empty body. - A redirect is refused, not followed, so credential headers never reach where it points: its 3xx is an answer like any other outside 2xx, and the send fails.
- A failure names the provider and the URL's origin only (a webhook's path or query is often its credential), plus the start of the answer with every secret the channel holds cut out before it is cut to 200 characters on a code point, so no piece of a secret survives at the edge.
- Credentials are trimmed of the spaces and newlines a paste leaves; a header value with a line break or NUL inside is refused, and so is a header name that is not an HTTP token (
Shared::headers(), inNativeHttpand the WordPress plugin'sWpHttpalike), so no header can add another. - At most 1 MiB of an answer's body is kept (
Http::MAX_BODY; curl stops reading there, the streams too, and WordPress getslimit_response_size): a channel reads 200 characters of a refusal, and a compressed answer from a hostile or broken endpoint could otherwise decode past PHP's memory limit, a fatal error no catch sees. Of an answer outside 2xx only 64 KiB is read (Http::ERROR_READ_MAX, the SDK'sERROR_READ_MAX), andShared::errorText()holds anHttpthat kept more to it too, a character cut at the end read as U+FFFD. - A body is sent as fetch sends a string, UTF-8 with U+FFFD for bytes that are not, and the webhook's HMAC and SES's SigV4 sign exactly those bytes.
- Ids are deterministic (the first 32 hex characters of SHA-256 over job, type, and time), so Resend's idempotency key, Sentry's event id, and Rollbar's UUID drop a resend of an alert a provider already took.
- Twilio sends one message per number, fitted to the segments allowed (GSM-7 at 153 a segment or UCS-2 at 67 once split, the extension table costing two, a character never split across two), the link kept whole. The alert counts as delivered when any number took it, each refusal going to
onErrorwith the number masked; it fails only when every number refused it.
A webhook URL (Slack's, Discord's, the webhook's) is cleaned as fetch cleans it before it is used: characters U+0000 to U+0020 around it dropped, and every tab, CR, and LF inside it removed (a pasted URL often ends in a newline). Only http and https with a host are posted to (Shared::postable), in the channels and again in NativeHttp and the WordPress plugin's WpHttp, on the curl path and the stream path alike; anything else is refused with only http and https URLs can be posted to, not ftp: (the scheme, when there is one with a host) or not this URL, never quoting it, since its path is the credential. A URL with a space left inside is refused as "this URL", as the gem refuses it, where fetch would escape the space. No error from either path, nor WordPress's, can carry the URL's path or query: Shared::scrub takes them out of curl's and PHP's messages, and Shared::origin cleans the URL the same way first.
The requests go through Alerts\Http (post($url, $body, $headers, $timeoutMs)), which every channel takes as http:. A channel given none uses Transport::default(): the Http set with Transport::set() (the WordPress plugin sets its wp_remote_post one when it boots), else a shared NativeHttp, which uses curl when the extension is loaded and an HTTP/1.1 client of its own on PHP's stream sockets otherwise, every step of it (connecting, the TLS handshake, sending, the answer's headers and body) under the one deadline, since PHP's http:// stream reads the headers before its caller can look at the clock (both tested against a local php -S server: the redirect, the deadline, a body that drips in, trimmed headers; the socket client also against tests/servers/raw.php: headers sent one line at a time, a chunked answer, TLS with a certificate the test makes). The URL, body, headers, and keys are #[\SensitiveParameter] wherever they are passed (NativeHttp, Shared, SigV4, the channels' and triage's constructors), so a failed send's trace, which an error tracker shows with its arguments, holds no credential; the WordPress plugin passes one on wp_remote_post, and the tests a fake. Slack's body and triage block are cut after escaping without splitting an &...; entity or a surrogate pair (Shared::cut, then back to before an & left without its ;). Text JavaScript would cut through a surrogate pair (Discord's slice, triage's prompt) keeps the lone half, as Js::slice16(), and Js::stringify writes it \ud83d as JSON.stringify does, so even those bodies are the SDK's bytes.
Triage\Anthropic is triage/anthropic.ts without the Anthropic client: the Messages API is one POST, so it sends it through Http itself, the request the official client makes (conformance/triage.json's wire cases, generated by driving the real client with a stub fetch): POST https://api.anthropic.com/v1/messages?beta=true, anthropic-version: 2023-06-01, anthropic-beta for the fallback beta, x-api-key, and the body byte for byte, the client's own telemetry headers (x-stainless-*, its user agent) left out. new Anthropic(apiKey:, model:, effort:, maxTokens:, fallbacks:, context:, baseUrl:, http:), the key defaulting to ANTHROPIC_API_KEY and the host to ANTHROPIC_BASE_URL. One attempt, no retries, with the smaller of 24 seconds and what the client's signal has left as its deadline; a signal already aborted sends nothing. A refused request is reported as Anthropic https://api.anthropic.com answered <status>: <body> with the key cut out, to onError as triage for <job>, and the alert goes out without a diagnosis.
sources: takes objects implementing Source (name() and sync(Cronwatch $host)), as the SDK's sources does. check() calls each, in order, after the store is ready and before anything else; one that throws is reported as source <name> and the check carries on, and the alerts a sync returns are added to the check's result. The host is the client: job(), recordRun(), $store, now(), and onError(). recordRun($run, evaluate: true) is the SDK's recordRun, claim and all, and refuses with the SDK's messages a status that is not one of the four, a startedAt that is not a finite number, a finishedAt or durationMs that is neither a finite number nor null, an error or output that is neither a string nor null, and a trigger that is not a string (one left out of an array is "run"), before anything is written.
Sources\PgCron($db, jobs:, prefix:, jobName:, options:, timezone:) is sources/pgcron.ts. On each check it reads cron.job and declares each job with its schedule from pg_cron: $ read as L, N seconds as every Ns, fields past the fifth dropped, and a paused job declared without its schedule. It then copies new rows of cron.job_run_details in as runs with ids pgcron:<prefix><runid>: the twenty newest quietly on first sight, 500 a page, and ten pages a check. A run queued without a start time is held ten minutes. A renamed, unscheduled, or unpicked job's old name is declared again without its schedule, so it is never missed. It reads through a pdo_pgsql PDO, a postgres:// URL (a connection of its own), or any object with query(string $sql, array $params): array, which is handed the SDK's SQL with $1 placeholders and PHP arrays for the array parameters; through PDO, arrays are bound as Postgres array literals. Settings are read from pg_settings, which never raises, so a role that may not read them never aborts the caller's transaction. Timestamps arrive from pdo_pgsql as text and are read as the SDK's driver reads them: pg's postgres-date takes the fraction as 1000 times its value, cut to whole milliseconds. PgCronTest replays conformance/pgcron.json, ports the SDK's (and the Python package's) source tests against a fake cron schema, and runs four more against the real extension when CRONWATCH_TEST_PGCRON names a Postgres with pg_cron preloaded.
conformance/ at the repo root holds JSON cases generated from the TypeScript build by scripts/conformance.mjs. ConformanceTest replays every case the core covers (duration, schedule, evaluate, format, health, output, and the store scripts against the memory, SQLite, MySQL, MariaDB, and Postgres stores), comparing values as the JSON the SDK writes; ChannelConformanceTest, TriageTest, and PgCronTest replay the channel, triage, and pg_cron fixtures; ClientConformanceTest replays client.json through the client over the memory store and every SQL store; and a fixture file nothing replays fails the suite. A behaviour change lands in TypeScript first, npm run conformance regenerates the fixtures, and this package is fixed until they pass. Phase 1 needed nothing new in the fixtures. Phase 2 added one thing: triage.json's wire cases, the HTTP request the official Anthropic client makes for a triage (URL, the headers that carry meaning, the body), since this port makes that request itself. Phases 3, 4, and 5 needed nothing new: the handler's tests are the SDK's, ported, and the framework integrations add no behaviour of their own to the core.
Croner parity is also checked against croner itself: ScheduleFuzzTest generates 3,000 expressions (valid and malformed, nicknames, names, ranges, steps, lists, L, W, LW, #, ?, +, six fields) in zones with and without daylight saving, from times around the clock changes, and the SDK in Node must give the same error message or the same fire times. NodeCompatTest has the built SDK and SqliteStore replay the same store calls into two files and compares what each reads of the other's and every column's bytes and SQLite type, and has a Node client and a PHP client take turns on one file and on one job's state version. Both need Node and the built SDK, and are skipped without them.
MemoryStore keeps everything in the process, cloned through JSON as the SDK's is. A PHP process usually ends with its request or its script, so it is for tests and trying things out; the client warns when it is the default in production.
SqliteStore($path, pdo:, prefix:) writes the same three tables as packages/sdk/src/stores/sql.ts: the same names (cronwatch_ prefix by default, the same prefix rules), the same CREATE text (so sqlite_master reads the same whoever created them), the same statements, parameters bound with their types, and the same JSON in the JSON columns. WAL mode, busy_timeout 5000, and synchronous NORMAL, the journal mode switched with the SDK's retry of a busy database, its directory created when missing, and the file made 0600 before SQLite opens it. So every process that uses one file (the web server, the crontab line, a queue worker) must run as the same user; a file, its -wal or -shm, or its directory that this process cannot read and write is refused before SQLite opens it, with a message that says so, rather than with SQLite's "attempt to write a readonly database" at the first write. A statement outside a transaction that SQLite answers busy (a snapshot made stale by another process's write, which the busy timeout does not wait out) is tried again within two seconds; a statement that failed is prepared afresh, since SQLite's busy answer leaves it unusable. deleteJob is one transaction, or joins the caller's.
MysqlStore($url, $username, $password, pdo:, prefix:) is the same tables for MySQL 8.0.13 or newer and MariaDB 10.6 or newer (both first class; CI tests MySQL 8.4 and MariaDB 10.11, the current long-term releases), in MySQL's dialect:
CREATE TABLE IF NOT EXISTS cronwatch_jobs (
name VARCHAR(255) NOT NULL,
definition LONGTEXT NOT NULL,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
PRIMARY KEY (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
CREATE TABLE IF NOT EXISTS cronwatch_runs (
seq BIGINT NOT NULL AUTO_INCREMENT,
id VARCHAR(255) NOT NULL,
job VARCHAR(255) NOT NULL,
status VARCHAR(255) NOT NULL,
started_at BIGINT NOT NULL,
finished_at BIGINT,
duration_ms BIGINT,
error MEDIUMTEXT,
output MEDIUMTEXT,
metrics LONGTEXT NOT NULL DEFAULT ('{}'),
`trigger` VARCHAR(255) NOT NULL DEFAULT 'run',
PRIMARY KEY (id),
UNIQUE KEY cronwatch_runs_seq (seq),
KEY cronwatch_runs_job_started (job, started_at DESC),
KEY cronwatch_runs_running (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
CREATE TABLE IF NOT EXISTS cronwatch_state (
job VARCHAR(255) NOT NULL,
state LONGTEXT NOT NULL,
PRIMARY KEY (job)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;The columns and values are the SDK's; what differs is only what MySQL needs:
- The JSON columns are
LONGTEXTholding the SDK's JSON byte for byte, never MySQL'sJSONtype, which reorders keys and rewrites numbers and spacing (MariaDB'sJSONis text, but one schema serves both). The state's version is read asEvaluate::stateVersion()reads it (a whole number from 0 to 2^53 - 1, else 0, so a foreign row's1.5or"x"is written over rather than refusing every compare-and-set of its job), the same text as the Go, Rust, and Elixir ports:CASE WHEN JSON_TYPE(JSON_EXTRACT(state, '$.version')) NOT IN ('INTEGER', 'UNSIGNED INTEGER', 'DOUBLE', 'DECIMAL') THEN 0 WHEN ... + 0 = FLOOR(... + 0) AND ... + 0 BETWEEN 0 AND 9007199254740991 THEN CAST(... + 0 AS SIGNED) ELSE 0 END, where...isJSON_EXTRACT(state, '$.version'). The type is tested before any conversion, since a string's conversion warns and strict mode makes that an error in anUPDATE; plus 0, MySQL'sJSON_EXTRACT(which answers JSON) and MariaDB's (which answers text) are both a number. A run's duration isEvaluate::runDuration(): 0 for a start after the finish, at most 2^53 - 1, so a foreign row's start near a 64-bit limit still makes a duration every store can write. - A
TEXTcolumn cannot be a primary key, so names and ids areVARCHAR(255), with theutf8mb4_bincollation so they compare as bytes, as SQLite and Postgres compare them ("b" and "B" are two jobs; names sort in byte order). seqbreaks ties between runs started in the same millisecond, as SQLite'srowidand Postgres'sseqdo.triggeris a reserved word, so the statements quote it.- There is no partial index, so
runs_runningindexes every status.CREATE INDEX IF NOT EXISTSis MariaDB's only, so the indexes are declared in the tables. - There is no
ON CONFLICT: upserts areON DUPLICATE KEY UPDATE.compareAndSetStatefrom version 0 is anUPDATEof a row at version 0 and, failing that, anINSERTthat a row already there refuses (a duplicate key); from any other version it is one conditionalUPDATE. Neither leans on how the connection counts affected rows, so an app's connection withFOUND_ROWSon gives the same answers. - MySQL refuses a subquery on the table a
DELETEdeletes from, soprunejoins in the newest start per job as a grouped derived table. - MySQL counts only the rows an
UPDATEchanged, soupdateRunIfover a row that already held those values answers 0; the store then reads the row and counts the write as made when it matches.
Given a URL or DSN (mysql:// or mariadb://, or a mysql: DSN; DATABASE_URL when given nothing) it opens a connection of its own, utf8mb4, native prepares, in autocommit mode, so its writes never join a transaction the app has open (a run recorded inside one survives a rollback), and connects again once when the server has gone away (errors 2006 and 2013) outside a transaction. A URL's TLS parameters become PDO attributes (MysqlStore::urlOptions()): ssl-mode (or sslmode) REQUIRED is TLS without checking the certificate, VERIFY_CA and VERIFY_IDENTITY check it, DISABLED and PREFERRED ask for nothing; ssl-ca, ssl-cert, and ssl-key (and libpq's spellings) are files that turn TLS on; TLS asked for without a CA uses OpenSSL's default CA file. Doctrine's serverVersion is ignored and any other parameter is refused, since dropping one without a word once let a URL that asked for TLS connect without it. A host, socket, database name, or charset holding ; is refused, since it would end its DSN field and start another. Given the app's PDO it shares it, transactions and all. CREATE TABLE, even IF NOT EXISTS over a table that is there, commits whatever transaction is open on the connection, and every PHP process calls init(), so init() first counts the three tables in information_schema and runs the CREATEs only when one is missing; the framework integrations run it as a migration instead.
PostgresStore($url, $username, $password, pdo:, prefix:) is stores/postgres.ts through pdo_pgsql. It keeps the same three tables, CREATE text, and statements as sql.ts's Postgres dialect: JSONB for the JSON columns, BIGINT times, seq BIGSERIAL breaking ties between runs started in the same millisecond, and name COLLATE "C" so names sort in byte order whatever the database's collation. The statements are written with ?, which PDO's native prepare numbers $1, $2 in order, so the server sees the SDK's text. Given a URL or DSN (postgres:// or postgresql:// with query parameters such as sslmode passed to libpq, a pgsql: DSN, or DATABASE_URL when given nothing) it opens a connection of its own in autocommit mode, so its writes never join a transaction the app has open. It connects again once when the connection broke outside one of its own transactions (SQLSTATE class 08, or 57P01 to 57P03); libpq reports a broken connection as inside a transaction, so the store keeps its own count. The DSN a URL becomes has each value quoted as libpq reads one (host='db';dbname='app'), so a space in a value (an options=-c search_path=x) never starts another keyword, and a value holding ; is refused, since PDO turns every ; into a space first; Laravel's connection settings are written the same way. Given the app's PDO it shares it, transactions and all. init() takes pg_advisory_xact_lock(hashtext('cronwatch:<prefix>')) in one transaction before CREATE TABLE IF NOT EXISTS, so processes starting at once take turns. Every PHP process calls init(), and CREATE INDEX IF NOT EXISTS takes a SHARE lock on its table even when the index is there, which, inside the app's open transaction on a shared PDO, would be held until the app commits and hold up every write of a run meanwhile; so init() first asks the catalog for the three tables and two indexes with to_regclass(), which takes no lock, and takes the advisory lock and runs the DDL only when one is missing. JSONB orders an object's keys by length and then bytes, so a definition, a state, or a run's metrics read back from Postgres has its keys in that order, as the SDK reads it; the values are the same. The store passes the store contract, the store fixtures, and the finish-once tests with real processes, as the others do.
Store has no lastRun(): the SDK made it optional, since the client never calls it, and the built-in stores (and Migrated) keep it for callers of their own. A store of the app's own needs only the rest.
Old runs are pruned by check() at most once an hour across every process sharing the store. The SDK keeps the hour in memory, which a long-lived Node process can; a PHP check usually runs in a process of its own (a crontab line every minute, a request), which would prune on every check. So the time is kept in the store as well, in the state table under the key _cronwatch:prune (no job's name: a job's starts with a letter or a digit), as a state whose prunedAt field is the last prune; a check takes the hour with the same conditional write every state goes through, so of several checks at once one prunes, and a time more than an hour from the clock either way does not hold pruning off. The row is an ordinary state row, so the tables, the store contract, and the other ports, which never read it, are unchanged. A worker that checks in a loop still keeps the hour in memory too, so it reads the row once an hour, not once a check.
A statement MySQL or Postgres sends again on a new connection may have landed the first time: error 2013 and a dropped Postgres socket can come after the server committed and before its answer arrived. For a write that is safe to repeat that changes nothing; for the conditional ones it would read as another process having written first, so a failure would be judged twice (its alert lost, its failures counted twice) or a finish taken for another's. So when compareAndSetState, updateRunIf, or deleteRunIf answers "not written" after such a resend, or insertRun hits the row its own first send wrote (which would read as another process having recorded the run, so a failure recorded just then would never be judged), the store reads the row, and counts the write as its own when the row holds exactly what it wrote (PdoStore::stateLanded(), compared whatever key order JSONB gave back). ClientHardeningTest breaks the connection after each of them lands.
$cw->routes(token:, basePath:, origin:, trustProxy:) is the SDK's cw.routes(): a Web\Dashboard whose handle(Web\Request): Web\Response is routes/index.ts line for line (the same URLs, JSON, status codes, headers, CSP, cookie, redirects, cross-site rule, token and cron secret rules, development token and its sign-in line (the host in it only when origin is set or the request's host is loopback), and 500s reported to onError as routes; a HEAD of a page or of a JSON read is answered as its GET, without the body, and never runs the check), and whose pages are routes/html.ts and routes/timeline.ts byte for byte, the CSS, the app shell (manifest, icons, service worker, app.js, the offline page), and the sign-in form included (it posts the token to <base>/signin, which sets the cookie and goes back to the page it came from; only a Bearer Authorization header is a bearer, so a proxy's Basic auth leaves the cookie to sign in). WebGoldenTest replays the gem's fixture, packages/ruby/test/web/golden.json (105 captures of the SDK's routes over a fixed seed, PNGs as base64, a HEAD of each read among them), three ways: a Request made by hand, the $_SERVER a server fills (what serve() reads) and a PSR-7 request through PsrHandler; every status, header, and body matches. A behaviour change lands in TypeScript first, and TZ=UTC node packages/ruby/test/web/golden.mjs regenerates the fixture for every port. The icons are the SDK's PNGs and SVGs, written into src/Web/Icons.php by scripts/make-dashboard-icons.mjs, whose --check (part of npm run check:conformance) covers the PHP file too.
One option is PHP's own, for a host that shows the dashboard inside its own pages (the WordPress plugin): head:, a callable given the base path, whose HTML each page's head carries in place of what Web\StandaloneHead writes there for the SDK's pages (the manifest link, the icons, app.js's script, and the stylesheet inline, Html::CSS, which the host then serves as a file of its own). Under it the app shell's manifest, service worker, app.js, and offline page are not served, since no page asks for them, and the icons still are. Without it every byte is the SDK's, which WebGoldenTest holds. The other, for a host whose jobs come from elsewhere (the WordPress plugin's, from WP-Cron), is empty:, HTML the board shows while there are no jobs in place of the SDK's line on declaring one in code; without it the line is the SDK's.
The development token's file (made once in a directory of the user's own under the system's temporary directory, read back on every later request) is Web\DevelopmentToken, which Dashboard calls only when it has no token and the environment is development, and only when the class is there (class_exists), so a host that never reaches it may leave the file out, as the WordPress plugin's zip does: its JSON API is given a token, and its wp-admin dashboard token: null, the opt-out.
It needs no framework and no package. Around the one core:
serve(), for a bare script such aspublic/cronwatch.php:Request::fromGlobals()reads$_SERVER(the request target as sent inREQUEST_URI, so a%2Fin a job name stays one; the headers fromHTTP_*,CONTENT_TYPE, and theAuthorizationApache hides inREDIRECT_HTTP_AUTHORIZATIONorgetallheaders(); the scheme fromHTTPS; the body fromphp://input, or$_POSTand$_FILESfor a multipart form, which PHP reads itself), andResponse::send()writes the status, the headers, and aContent-Lengthwithheader(), then the body unless the method is HEAD (so a HEAD'sContent-Lengthis its GET's). PHP's ownX-Powered-Byis removed and its defaultContent-Typeis not added to a redirect, so the headers on the wire are the SDK's;WebServeTestchecks them underphp -S.- PSR-7 and PSR-15, for Slim, Mezzio, and anything that speaks them:
Request::fromPsr()andResponse::toPsr($responseFactory, $streamFactory)are duck typed, so nothing requirespsr/http-message;PsrHandler(aRequestHandlerInterface) andPsrMiddleware(answers under its base path, hands the rest to the next handler) implement the interfaces and are the only files that load them. A PSR-7 URI escapes a%that starts no escape (%zzbecomes%25zz), so the request target in the server parameters (REQUEST_URI) is used when it is the same path.
A body is read only once a route wants it (silence, which is after the token and the job's lookup), and at most Request::MAX_BODY (1 MiB; the forms and JSON are a few bytes) and one byte more is read to tell: from php://input under serve() (and in wp-admin) and a PSR-7 stream a chunk at a time; HttpFoundation's content is read as the framework reads it for any controller, a string, since reading it as a stream would stop anything later asking for the string, and its Content-Length is looked at first. A body past it, or a Content-Length saying so, is answered 413 ({"ok":false,"error":"Request body too large"}, or the HTML route's message page), as the Python package answers; the SDK leaves this to the server in front of it. handler() never reads a body. PHP itself has already taken a POST body up to post_max_size into memory or a temporary file under serve(), which a server's own limit bounds.
Paths are read as the URL parser leaves them (backslashes as slashes, characters a browser escapes escaped, . and .. segments resolved), and the query as URLSearchParams reads it. The base path defaults to the script for a path-info URL (/cronwatch.php/api/jobs, when SCRIPT_NAME is a .php file that is the script running), else /cronwatch, the SDK's default; php -S with a router names the request path as the script, which is not a mount. Only the base and the paths under it are the dashboard's: a path that merely starts with the base's text (/cronwatchapi/jobs) or lies outside it once its dot segments are resolved (/cronwatch/../api/jobs) is the HTML 404 page at once, before any sign-in, as in the SDK. The origin option is read as new URL(value).origin reads it (Web\Origin: IPv4 numbers written out, IPv6 compressed, a host outside ASCII as punycode through intl when it is loaded, the default port dropped).
The token is string|FromEnv|false|null: the default, FromEnv::Read, reads CRONWATCH_TOKEN, "" counts as unset, and null serves the dashboard open, as in the SDK and every other port (as cronSecret: has it). PHP has no undefined, so "not given" is the enum case Cronwatch\FromEnv::Read rather than a missing key; a wrapper that passes every argument on can pass it to say "read the environment". Until 0.11, null was the default and read the environment and false opted out; false still opts out, deprecated, and goes in 2.0. A configured token shorter than 24 characters is reported once per Dashboard through onError (as routes), as short enough to guess, and still used, since the SDK takes any token and refusing one would lock out a dashboard that worked; the WordPress plugin, the Drupal module, and the Craft plugin refuse one that short themselves. With no token in development the SDK makes one per process and prints the sign-in line on its first request; a PHP process usually lasts one request, so the dashboard keeps the token it makes in a file (sys_get_temp_dir()/cronwatch-<uid>/dev-token-<hash of the working directory and base path>, written whole to a draft from tempnam() in the same directory and then link()ed into place, which fails when the name exists, so two requests at once agree and neither ever reads a file still empty, mode 0600, in a directory of the user's own made 0700; a directory or file that is a link, is not the user's, or can be read or written by anyone else is not used, so another user of a shared temporary directory can neither plant a token nor read one, and without a safe place the token lasts one request, so nothing is served), and writes the line to the server log (error_log(), or the log: callable given to new Web\Dashboard) once, when it makes the token. Delete the file for a new one.
$job->handler($fn, secret:) is the SDK's handler(), for a platform cron that calls a URL: $fn(JobContext $job, $request) runs as a recorded run with the trigger "handler" for each request whose Authorization is Bearer <secret>, read as the dashboard reads it (the scheme in any case, then any whitespace) and compared in constant time. The secret is the handler's own, else the client's cronSecret (CRON_SECRET by default; "", a string of only whitespace, and FromEnv::Read each mean the client's secret); secret: null, or a client made with cronSecret: null, lets anyone run the job, as in the SDK (the handler's default is "", the client's secret; false does what null does, deprecated since 0.11). With no secret and no opt-out, outside development, it answers 503 with the SDK's JSON and reports to onError once per client as handler; a wrong or missing bearer is 401 {"ok":false,"error":"Unauthorized"}. A run is answered with {"ok","job","run","status","durationMs"}, 200 or 500, with the error's first line as "error" only when a secret was required; the same bytes and headers (content-type: application/json; charset=utf-8, cache-control: no-store) as the SDK's json(), written by Js::stringify. A function that returns a response is answered with it as it is. An exception is answered with the 500 (executeOutcome(), the run recorded as run() records one, without throwing).
A response, returned by any run's function (the handler's, run()'s, a scheduled callback's, a queued job's) or given to finish(['result' => ...]), fails the run when its status is 400 or more, with the error HTTP <status> and its reason phrase when it carries one (HTTP 503 Service Unavailable), as the SDK has a fetch Response. PHP has no one response type, so Web\ResponseStatus reads the common ones by duck typing, needing no package: Web\Response; anything whose getStatusCode() answers a whole number from 100 to 599 (PSR-7 responses, Symfony's and Laravel's HttpFoundation responses, Symfony's HttpClient responses; one that throws asking, a client that never connected, is not a response); Laravel's HTTP client response (status(), reason()); and an array with a whole-number status from 100 to 599 and no key but status, headers, and body (the handler's own shorthand, ['status' => 202, 'body' => 'queued']). The reason is the one the response carries: PSR-7's reason phrase, HttpFoundation's status text (read from its protected property), else "".
A request is anything with headers: a Web\Request, a Symfony or Laravel request (headers->get()), a PSR-7 request (getHeaderLine()), or an array of server variables (HTTP_AUTHORIZATION, or Apache's REDIRECT_HTTP_AUTHORIZATION) or with a headers array. The function gets the request as it was given. Around the one respond():
serve(), for a bare script (public/cron/nightly.php): the request from the superglobals (Web\Request::fromGlobals(), the Authorization header found where Apache hides it), the answer written withheader()andecho(aWeb\Responseby itssend(), an HttpFoundation response by its own, a PSR-7 one copied out).HandlerTestruns it underphp -S.__invoke($request)answers in the request's kind: an HttpFoundation response for a Symfony request (Laravel'sIlluminate\Http\Responsefor a Laravel request, so Laravel middleware that call its helpers work), aWeb\Responseotherwise. An array response is made into the request's kind, since Laravel would send an array as JSON with status 200. So a Symfony controller isreturn $cw->job('nightly')->handler($fn)($request);, and a Laravel route can be the same closure.laravel(), a route action typed with Laravel's request, so the router injects it:Route::post('/cron/nightly', $handler->laravel()). A route inroutes/web.phpgoes through Laravel's CSRF middleware, which refuses a cron's POST; put it inroutes/api.phpor exclude it (->withoutMiddleware(PreventRequestForgery::class),ValidateCsrfTokenbefore Laravel 13).symfony(), the same for a route defined in PHP.Web\PsrJobHandler($handler, $responseFactory, $streamFactory), a PSR-15 request handler, for Slim, Mezzio, and the rest: a PSR-7 response the function returned is passed on, an HttpFoundation one copied into PSR-7, and the handler's own answer made with the PSR-17 factories. It is a class of its own (as the dashboard'sPsrHandleris) so the PSR interfaces are loaded only by an app that uses it.
tests/HandlerTest.php has the SDK's handler tests (client.test.ts and client-hardening.test.ts), the response kinds, finish() with a response, the bearer from every request kind, PSR-15, and serve() under php -S; tests/Laravel/HttpTest.php and tests/Symfony/HttpTest.php the handler as a Laravel route and in a Symfony controller.
Each integration maps the framework's scheduler onto jobs and gives the check a home. The core above is what they share; each is its own entry class, needing only its framework.
How they ship. The Laravel and Symfony integrations are in this package, cronwatch/cronwatch, under Cronwatch\Laravel and Cronwatch\Symfony, as Sentry's, Spatie's, and Monolog's are: Laravel finds the service provider through extra.laravel.providers in composer.json (package discovery), and a Symfony app registers the bundle in config/bundles.php (Flex does it for a recipe; there is none yet). Neither framework is a Composer requirement (the core still requires only ext-json and ext-pcre): the classes are loaded only by the framework that names them, and nothing in the core names them. Separate packages (cronwatch/laravel, cronwatch/symfony-bundle) were weighed and left out: each would be a thin layer over the same classes, released in step with the core and split from the monorepo as the core is, three packages and three version constraints to keep in line for a few thousand lines. One package means one version to install and one changelog, and an app that uses neither framework loads none of it. The cost is that composer.json cannot state the supported framework versions (a conflict on laravel/framework would stop a Laravel 11 app installing the core too), so they are stated here and tested in CI. The WordPress plugin's zip leaves Laravel/, Symfony/, Bridge/ (but ChannelSettings), and what only they use out.
The CMS integrations are packages of their own, because their hosts install them as such: a Drupal module is a drupal-module package that drupal.org serves (as drupal/cronwatch) and Drupal enables by its machine name, and a Craft plugin is a craft-plugin package with its plugin class named in composer.json, which the Plugin Store lists from Packagist (cronwatch/craft). Each lives in this monorepo (drupal/, craft/), requires cronwatch/cronwatch at its own release (^X.Y.Z, bumped by release.mjs), holds only the host's glue (the library does the work), and is split to the repository its host reads (see Releasing). .gitattributes keeps both directories out of the core's archive.
- Plain crontab. A script wraps its work in
$job->run(), and a second crontab line runs the check every five minutes:vendor/bin/cronwatch check, a Composer bin, so that line needs no script of its own. It finds the app's client in a bootstrap file, a PHP file that returns theCronwatchclient with its jobs declared (or a callable that returns it), the same file the app's own scripts canrequire:--bootstrap <file>, elseCRONWATCH_BOOTSTRAP, elsecronwatch.php, elseconfig/cronwatch.phpin the working directory. Composer's autoloader is loaded first (the one Composer 2.2 and newer name to a bin script, else found by path). It prints the line the gem's rake task and the Python package'scronwatch_checkprint,cronwatch: checked 3 jobs, sent 1 alert(--quietfor none), and exits 0; anything that goes wrong is one line on standard error and exit status 1, so cron mails it.CheckResult::summary()is that line, for the framework commands to print. - Laravel. A service provider,
Cronwatch\Laravel\CronwatchServiceProvider, found by package discovery. Laravel 12 and 13 are supported (11 left security support in March 2026); CI tests 12 on PHP 8.2 and 13 on 8.5, with Orchestra Testbench.- The client.
Cronwatch\Cronwatchis a singleton made fromconfig/cronwatch.php(merged from the package's copy, published withvendor:publish --tag=cronwatch-config), every value read from the environment as Laravel's own config is: the store, the channels (mail through the app's mailer, Slack, Discord, a webhook, a log channel, and AlertChannel classes the container makes; with none set, alerts go to the app's log), Claude triage,CRON_SECRET, retention, defaults, and delivery.onErrorwrites to the app's log with the exception. The app's environment ($app->environment()) is the fallback ofEnv, solocalis development andtestingis not. An app that wants a client of its own bindsCronwatch\Cronwatchagain in its own provider'sregister(), which runs after the package's. - The store.
database(the default) keeps the three tables in the app's database:DatabaseStorereads one ofconfig/database.php's connections (the default, orCRONWATCH_DB_CONNECTION), MySQL and MariaDB, Postgres, or SQLite, as Laravel has resolved it (aDB_URLtaken apart, the write side of a read/write split, the first of a list of hosts), and opens a connection of CronWatch's own with those settings: host, port, database, credentials, socket, the PDO options (SSL), Postgres's sslmode and certificates, andsearch_path. So its writes never join a transaction the app has open (a run recorded insideDB::transaction()survives the rollback, whichProviderTestchecks on SQLite and on each server), and the stores' own reconnection applies to it. An in-memory SQLite database (:memory:, as tests use) cannot be opened twice, so that one is shared. The tables are CronWatch's names (CRONWATCH_TABLE_PREFIX, defaultcronwatch_); the connection's own table prefix does not apply to them, so every port reads the same tables.sqlitekeeps a file of its own (storage/cronwatch/cronwatch.dbby default), andmemoryis for tests. SQL Server is refused with a message. - The migration.
migrations/2026_09_28_000000_create_cronwatch_tables.phpis loaded withloadMigrationsFrom(for the database store;CRONWATCH_MIGRATIONS=falseto leave it out) and publishable withvendor:publish --tag=cronwatch-migrations. It calls the store's owninit(), so the tables'CREATEtext is the SDK's for the database, byte for byte, through the store's connection, withIF NOT EXISTS(running it where the tables are already there changes nothing);down()drops them. Each process still runsCREATE TABLE IF NOT EXISTSon first use, as every store does; for a database user who may not create tables,CRONWATCH_CREATE_TABLES=false(create_tables) wraps the store inStore\Migrated, whoseinit()does nothing.down()drops the tables the store names ($store->prefix), so it reads no setting itself. - Settings keys.
table_prefix,create_tables,check.scheduleandcheck.frequencyare spelled as the Symfony bundle spells them, top-level for the tables since the bundle'sstoreis a URL, as isdashboard.token;site/docs/laravel.mdhas every key beside the bundle's. Before 0.11 they werestore.prefix,store.create_tables,schedule.checkandschedule.check_cron.Settings(internal) reads each: Laravel merges the package's config file into the app's at the top level only, so a new key is always present (the package's default) and cannot say whether the app set it, while an old key can only have come from the app's published file. So an old key, when present, is read in place of the new one, with anE_USER_DEPRECATEDnotice once per key per process (@trigger_error, which Laravel logs to itsdeprecationschannel), through 1.x; 2.0 drops them.ProviderTestchecks a pre-0.11 file (the prefix,create_tables, the check's cron, and the notices) and that the new keys alone give none. - Watching the scheduler with no code changes. The provider listens to the scheduler's events (
ScheduleWatcher):ScheduledTaskStartingstarts a run (trigger"laravel-scheduler","schedule"before 0.11, whichrunToFinish()still accepts so a background task running across the upgrade is finished; throughstartExecution(), the first half ofrun(), so a callback'sCronwatch::current()is its context and a process that dies mid-task records the run as interrupted);ScheduledTaskFinishedends it, failed withExited with code Nfor a command that exited non-zero,Returned falsefor a callback that returned false, else ok, a callback's return value treated asrun()'s (a string is the output, an HTTP response of 400 or more fails it);ScheduledTaskFailedends a run still open with its exception (a callback that threw; Laravel dispatches it afterScheduledTaskFinishedfor a command's exit code, when the run is already ended).ScheduledTaskSkipped(a filter said no, or the schedule is paused) records nothing. - Output. A command's output is what it wrote: its own file (
->sendOutputTo(), or->appendOutputTo()read from where the file stood when the run started, the file left as the task left it), or, for a task that sends its output nowhere (Laravel's default,/dev/null), nothing, unlesscronwatch.schedule.capture_output(CRONWATCH_CAPTURE_OUTPUT) is on: then a temporary file of CronWatch's own for the run, the task's setting put back after. That file holds all the command writes until the run ends (the shell writes it, so nothing can trim it while the command runs), which is why it is opt-in; a file of CronWatch's own more than a day old (a run killed before it ended) is swept by the next run that makes one. At most the last 256 KB is read; the run keeps the SDK's cap. A failed command's output is kept with its error. - Overlapping, one server, background. A task
->withoutOverlapping()whose last run still holds the lock is skipped by Laravel afterScheduledTaskStarting, so the watcher checks the lock ($event->mutex->exists()) before it starts a run, and records nothing (Laravel 13 also setsskippedBecauseOverlapping: when the lock was taken between the two looks, the run already started is taken back withdiscardExecution(), as a released queued job's is, and the job's state is left as it was). A task->onOneServer()runs, and is recorded, on the server that took it; the others dispatch nothing, so a store shared by the servers (the database) sees one run. A task->runInBackground()starts its run inschedule:runand is finished byschedule:finish, which Laravel runs when the command ends, in a process of its own (ScheduledBackgroundTaskFinished): the oldest runningschedulerun of the task's job, else the newest a check has markedtimeout(a task that ran past its timeout and has now ended), is resumed and finished with the exit code and the output, which went to a file both processes can name (storage/framework/schedule-cronwatch-<sha1 of the task's mutex name>.log). Sub-minute tasks (->everyTenSeconds()) record every run. - Jobs.
ScheduledTasksnames each task (the rule is in its docblock and the README): a command after the command as written without PHP and artisan (emails:send), with its arguments made a name byBridge\JobName(emails:send --forceisemails:send-force-<8 hex>); a shell command the same way; a callback after its name or description (->name('prune-tokens')), else its class and method, else, for a closure,closure:<file>:<line>, which moves when the line does, so the README asks for a name; aSchedule::job()after the job's class, backslashes as dots. The schedule is the event's cron expression and timezone ($event->expression,$event->timezone, else the app's). A task with filters (->when(),->skip(),->between(),->unlessBetween(); not the skip->withoutOverlapping()adds, which only defers to a run still going) does not run at every time its expression names, so its job has no schedule: its runs and failures are 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 toonError; a schedule CronWatch cannot read (a zone that is not IANA) is reported, and the task watched without it.->cronwatch([...]), a macro on scheduled events, gives a task job options (name,grace,timeout,expect,schedule,tags, and the rest), and->cronwatch(false)leaves it out; the options are kept beside the event in aWeakMap(PHP 8.2 deprecates adding a property to it). Jobs are taggedlaravel-schedulerandlaravel-scheduler:<app>, the app's name (CRONWATCH_APP_ID, elseapp.name) made a tag (Bridge\Unscheduled::appTag(): lowercased, anything but letters, digits,.,_, and-made-). The check itself (cronwatch:check) is never a job. - Declared when. As
schedule:runandschedule:finishstart (CommandStarting), at the first scheduler event, and by the check, which also declares every job taggedlaravel-scheduler:<app>with this app's name that is no longer in the schedule again without its schedule (Bridge\Unscheduled, the WordPress plugin's rule), so a task taken out is never reported missed and one that never ran is. The app's tag is what keeps two apps sharing one store and table prefix from declaring each other's jobs without a schedule (each would otherwise take the other's tasks for its own taken out, and hide their missed runs), so two such apps need different names:CRONWATCH_APP_IDwhen theirAPP_NAMEs are the same (Laravel's default isLaravel). The name is the app's, not its path, since a deploy that puts each release in a directory of its own would otherwise orphan the jobs of the tasks it took out. A job taggedlaravel-scheduleralone (by an earlier version) is taken for this app's, and given its tag, only while no other app'slaravel-scheduler:<app>tag is in the store; with another app there it could be either's and is left alone.ScheduleTestchecks two apps on one store, andBridgeTestthe older tags. - The check.
php artisan cronwatch:checkdeclares the schedule and checks, printing the line every port prints (cronwatch: checked 3 jobs, sent 1 alert); the provider schedules it every five minutes (CRONWATCH_CHECK_CRON) in the app's own scheduler, which is running every minute wherever scheduled tasks work (CRONWATCH_SCHEDULE_CHECK=falseto leave it out). - Queued jobs. A job class marked
#[Cronwatch\Watch(...)](its options as named arguments,enabled: falseto opt out) or implementingCronwatch\Laravel\ShouldBeWatched(options from a staticcronwatch()method when it has one) is watched through the queue's events (QueueWatcher), in the worker that runs it, trigger"laravel-queue"("queue"before 0.11):JobProcessingstarts the run (Cronwatch::current()is its context),JobProcessedends it ok, or failed when the job marked itself failed;JobExceptionOccurredandJobFailedend it failed with the exception, whichever comes first;JobTimedOutends it (<Job> has timed out.) before the worker kills itself. Every attempt is a run of its own, its id the queued job's uuid and 12 random hex digits, so the gem's Sidekiq rules and the Python package's Celery rules hold: failing attempts open one failed alert, the attempt that succeeds closes it, andfailuresBeforeAlertrides through retries. A job released back onto the queue without an exception (aRateLimitedorWithoutOverlappingmiddleware, or a deliberate$this->release()) did not fail and did not do its work, so its attempt is not a run:JobProcessedsees it released, and the run started atJobProcessingis taken back (Cronwatch::discardExecution(), which deletes the row while it is still running and of this job, through the store'sdeleteRunIf,Store\DeletesRunIf, which every store here implements). Nothing is judged or alerted, the job's consecutive failures are left as they were (two failures either side of a release are two in a row), and an open failed alert stays open until an attempt succeeds. Recording it as neither failed nor ok was weighed: the SDK's run statuses arerunning,ok,failed, andtimeout, and a fifth would be a change to every port and the dashboard; anokrow would make the job look healthy between failures (health reads the last run), and afailedone would make it look failing. What the released attempt logged is dropped with it. A row a check already marked stuck (the release came after the job's timeout) is left as it is and reported toonErrorasdiscarding <job>, as is a store of the app's own withoutdeleteRunIf. A release after an exception is that exception's failed attempt, as Celery'sself.retry()is. A job a middleware skips without releasing it (WithoutOverlappingorRateLimitedwith->dontRelease(),Skip) is deleted by Laravel with its handler never run, andJobProcessedcannot tell it from a success, so the watcher adds a pipe to the command bus (Dispatcher::pipeThrough(), the app's own pipes kept, and put back if the app sets the pipes later), which runs only once the job's own middleware let it through and notes that the attempt reached its handler; an attempt that did not is taken back like a release. Only a job class run byCallQueuedHandlerthat usesInteractsWithQueue(so the command carries its queue job) is judged this way; any other is ended as before.QueueTesthas a job released by itself and by its middleware, failures around a release withfailuresBeforeAlert: 2, and a release after the job's timeout. A queued listener, mailable, or notification is watched by its own class, as the worker names it. The sync queue fires the same events. ASchedule::job()of a watched class is the queued job's: the scheduler declares the schedule under the queued job's name and options and records no run of its own (the dispatch), the worker records the job's, and a worker declaring the job gives it the schedule too, so neither process takes the other's schedule away. - The dashboard. Mounted at
/cronwatch(CRONWATCH_PATH, or a domain of its own), every path to one controller that hands the request to the library'sDashboard(Web\HttpFoundationturns Laravel's request into the dashboard's and its answer into Laravel's response). The route group's middleware iswebandHttp\Authorize, which asks theviewCronwatchgate with the signed-in user (by default, as Horizon's and Telescope's gates do, it lets anyone in thelocalenvironment only; an app defines the gate to widen it); a request that passed it is served open (token: false), the app's sign-in standing for the token. A request carrying a bearer token skips the gate and must carry the dashboard's token (CRONWATCH_TOKEN) or, for/api/check,CRON_SECRET, for@cronwatch/mcpand a platform cron, which have no session. Laravel's CSRF middleware (PreventRequestForgeryin 13,ValidateCsrfTokenbefore) is taken off the route (the forms carry no Laravel token, and the routes refuse cross-site writes themselves), and the origin they compare with is the request's as Laravel sees it, so the app's trusted proxies apply. - Tests.
tests/Laravel/on Orchestra Testbench:ScheduleTest(every kind of task named, the options macro, filters, environments, collisions,schedule:runrecording callbacks and shell commands, output files, overlapping, a background task finished byschedule:finish,Schedule::job(), the check and unscheduled jobs, two apps on one store, a task Laravel skips for overlapping after it started),QueueTest(a real worker,queue:workon the database queue: retries failing until one succeeds, the last attempt failing, a release by the job and by its middleware taking back the attempt, opting in and out, the sync queue),HttpTest(the gate, a bearer, CSRF, the path, a handler as a route, an HTTP client response as an outcome), andProviderTest(the store through its own connection surviving a rollback, on SQLite, MySQL, MariaDB, and Postgres; the migration'sCREATEtext;create_tables; the channels; the mailer; the environment).
- The client.
- Symfony. A bundle,
Cronwatch\Symfony\CronwatchBundle, registered inconfig/bundles.phpand configured inconfig/packages/cronwatch.yaml. Symfony 6.4 LTS, 7.4 LTS, and 8.1 are supported (7.3 and 8.0 are out of maintenance); CI tests 6.4 and 7.4 on PHP 8.2 and 8.1 on 8.5 (8.x needs 8.4), with FrameworkBundle's test kernel, the Scheduler, Messenger, and SecurityBundle.- The client. The
Cronwatch\Cronwatchservice (aliascronwatch) from the bundle's configuration:store, a URL written as Doctrine writes one (Symfony\Stores:mysql://,postgresql://,sqlite:///<path>,memory, Doctrine'sserverVersionand a Postgrescharsetleft out), by default the app'sDATABASE_URL, elsevar/cronwatch.db, always through a connection of CronWatch's own;store_servicefor a store service of the app's;table_prefix;create_tables(false wraps the store inStore\Migrated); the channels (mailerthrough symfony/mailer,slack,discord,webhook,log, and AlertChannelservices; with none, alerts go to the logger, Monolog'scronwatchchannel when the app has Monolog); triage;cron_secret; retention, defaults, and delivery. The kernel's environment is the fallback ofEnv. - Watching the Scheduler with no code changes.
SchedulerSubscriberlistens to the Scheduler'sPreRunEvent,PostRunEvent, andFailureEvent, which the worker consuming a schedule (messenger:consume scheduler_default) dispatches around each message:PreRunEventstarts a run (trigger"symfony-scheduler","scheduler"before 0.11; the handler'sCronwatch::current()is its context), after every other listener so a run another cancels is not started;PostRunEventends it ok with the handler's result (a string is the output, an HTTP response of 400 or more fails it; on 6.4, whosePostRunEventhas nogetResult(), the result is kept from Messenger'sWorkerMessageHandledEventjust before);FailureEventends it failed with what the handler threw, taken out of Messenger'sHandlerFailedException. - Jobs.
ScheduledMessagesreads every schedule (thescheduler.schedule_providerservices, so#[AsSchedule]providers and#[AsCronTask]and#[AsPeriodicTask]tasks) and names each recurring message: a message object after its class, backslashes as dots; a command (RunCommandMessage) after the command as written, as Laravel's are; a service method (ServiceCallMessage) after the service id and the method unless it is__invoke; aRedispatchMessageafter the message it carries. A schedule other thandefaultputs its name in front (reports:App.Message.Build).#[Cronwatch\Watch(...)]on the message's class names it and gives options (or leaves it out), and the bundle'sscheduler.jobsdoes the same by job name (scheduler.excludeleaves names out). The schedule is the trigger's: aCronExpressionTrigger's expression (a hashed#one as Symfony resolved it) and zone, read from the trigger; aPeriodicalTrigger's interval asevery <seconds>s; aJitterTrigger's inner trigger. A trigger that does not fire at fixed times (ExcludeTimeTrigger,CallbackTrigger, a calendar interval such as1 month) leaves the job without a schedule, reported once, and so does aPeriodicalTriggerwhosefromis still to come or whoseuntilhas passed (by the client's clock, when the job is declared; a worker's scheduler sets an unsetfromto the time it starts, which is never ahead of it). Collisions, tags (symfony-schedulerandsymfony-scheduler:<app>, the app named by the bundle'sapp_id, elseapp-and 12 hex characters of a SHA-256 of the project directory, a release's directory (<root>/releases/<name>, as Deployer, Capistrano, and Envoyer lay them out) read as its root so the tag outlives a deploy. Nothing secret goes into the tag, which the dashboard and the API show: before 0.11 it hashed the kernel's secret, which published 48 bits of a fast hash ofAPP_SECRET. A job that still carries the tag from the secret is taken for this app's too, so one taken out of the schedule since is declared without it. Two apps sharing a store, a table prefix, and a path (two containers at/app) need anapp_ideach), the check never being a job, and jobs of messages taken out of a schedule are as for Laravel. - Messenger.
MessengerSubscriberrecords messages marked#[Cronwatch\Watch]where a worker handles them (WorkerMessageReceivedEvent,WorkerMessageHandledEvent,WorkerMessageFailedEvent; trigger"symfony-messenger","messenger"before 0.11), each attempt a run, so a retried message's failing attempts open one failed alert and the one that succeeds closes it. A schedule's own transports (scheduler_<name>) are the Scheduler watcher's. A message a schedule sends on to a transport (RedispatchMessage) is the handler's run when its class is watched: the schedule declares the schedule under the carried message's job and records no run of the dispatch, and the worker that handles it records the run (newer Symfony keeps the ScheduledStamp on the carried message and dispatches the Scheduler's events where it is handled too; those are left to this watcher). A message handled synchronously never reaches a worker and is not a run. - The check.
bin/console cronwatch:check, andCheckMessage, whose handler declares every schedule's jobs and checks. The bundle addsCheckMessageevery five minutes (check.frequency, a period or a cron expression) to the app'sdefaultschedule (check.schedule), so the worker that already runs the app's scheduled messages (messenger:consume scheduler_default) runs the check with no step of its own. A check in a schedule nobody consumes does nothing, and before this the bundle's check was a schedule of its own,cronwatch, which an app had to consume by name (scheduler_cronwatch) and could easily miss; a job that never ran was then never reported, silently. SoCheckSchedulePass(before the Scheduler's own pass) makesCheckScheduledecorate the app's provider for that schedule (its#[AsSchedule]class, or the one#[AsCronTask]tasks make) and add the check to itsScheduleonce, unless the schedule already sends aCheckMessage; with no provider for that name,CheckScheduleis the schedule's provider, and the Scheduler makes its transport.check.schedule: cronwatchgives the check a schedule of its own (consumescheduler_cronwatchtoo), andcheck.schedule: falseleaves it out, for a crontab line running the command. Off by default was weighed and left out: an app that turns nothing on gets no check, the failure this is meant to prevent, where the default schedule is the one transport every app that uses the Scheduler consumes.cronwatch:check --statuschecks nothing and says where the check is and what a worker must consume (cronwatch: the check runs every 5 minutes in the "default" schedule, only while a worker consumes it: bin/console messenger:consume scheduler_default), then each schedule with its transport and the number of messages watched;debug:schedulerlists the check in its schedule. A warning logged when no check has run for an hour was weighed and left out: nothing is stored when a check runs, and the process that would notice (a worker running the app's schedules) is the one that runs the check, so with nothing consuming the schedule there is nothing to log it either. - The dashboard.
@CronwatchBundle/config/routes.php, imported under a prefix (/cronwatch), routes every path toDashboardController. Behind the app's security, with anaccess_controlrule for the prefix, a signed-in user granteddashboard.role(ROLE_ADMINby default) is served open, the app's sign-in standing for the token; anyone else, and every request carrying a bearer token, must present the dashboard's token (dashboard.token, elseCRONWATCH_TOKEN), orCRON_SECRETfor/api/check, so a prefix left outside the firewall is still closed. The origin compared for writes is the one Symfony sees, so its trusted proxies apply. - Tests.
tests/Symfony/on a MicroKernel with FrameworkBundle, SecurityBundle, and the bundle:SchedulerTest(every kind of recurring message named, options and exclusions, a real worker running a schedule, a Messenger message retried until it succeeds, a message a schedule sends on, the check and unscheduled jobs, the check in the app's default schedule, in one of its own, or in none, and--status, two apps on one store, watching turned off),HttpTest(an admin through HTTP basic, a viewer and a stranger needing the token, a bearer,CRON_SECRET, cross-site writes, a handler in a controller), andConfigTest(store URLs, channels,create_tables, the logger default, the environment).
- The client. The
- WordPress. A plugin, since WP-Cron is where missed runs are most common: it fires only when someone visits the site, so a quiet site's events run late or not at all, and that is exactly what CronWatch reports. The source is
wordpress/(cronwatch.php,uninstall.php,readme.txt,includes/in the namespaceCronwatch\WordPress);php wordpress/build.phpmakes the plugin directory's zip,cronwatch/with the plugin, the library'ssrc/inlib/src, and its MIT text inlib/LICENSE, nothing else, the dashboard's stylesheet ascss/dashboard.css(written fromHtml::CSSas the zip is built, not checked in, so it cannot drift from the library's pages;PluginTestchecks the two are the same bytes), byte for byte the same on every build, and refuses to build unlessCronwatch::VERSION, the header'sVersion, and the readme'sStable tagagree and the readme's changelog has a= X.Y.Z =section for that version (release.mjsbumps all three and adds the section). The zip leaves out the library's files the plugin never runs (LEFT_OUTinbuild.php):NativeHttp(every request goes throughwp_remote_post, so the plugin directory's Plugin Check and its reviewers find no curl calls) and Claude triage (Triage/), so the zip holds no code that contacts a service its readme does not name (every alert channel ships, since the settings offer each, and the readme names each service);StandaloneHead(the plugin gives the dashboard its own head, so no zipped file writes a<script>or<style>element);DevelopmentToken(the plugin always gives the dashboard a token,falsein wp-admin and the owner's for the API, so no zipped file keeps a file in the temporary directory); the PDO stores (it stores through$wpdb), the pg_cron source, the command-line check, the PSR-15 adapters, and the Laravel and Symfony integrations with what only they use (Bridge/butChannelSettings,Watch,Store\Migrated). A developer who wants triage installs the Composer package, whose autoloader finds the classes the zip leaves out, and adds it throughcronwatch_client_args.PluginTestchecks that no zipped file callscurl_*, opens a PDO connection, or writes a script or style element, that every channel ships, since the settings can name each, and that nothing zipped names a left-out class (but forTransport'sNativeHttp,Html'sStandaloneHead, andDashboard'sDevelopmentToken, each reached only when nothing else was given, and the plugin always gives something;Dashboardalso asksclass_existsfirst).- Changelog. The readme's changelog is what the directory shows under a release, so a released section is never rewritten:
release.mjsadds the new version's section, renaming a= Unreleased =section written ahead (the release's notes) to= X.Y.Z =, or, without one, putting a placeholder entry on top and saying so, to be edited before tagging. The 0.5.0 section is the first release's, not yet published, so phase 3 extended it rather than adding one. - Licence. The directory requires GPL or GPL compatible code. The plugin itself is GPLv2 or later; it bundles this library under MIT, which is GPL compatible, so the zip as a whole is distributable under the GPL while the library stays MIT for everyone else. The readme says so.
- Watching WP-Cron with no code changes. The plugin observes
pre_reschedule_eventandpre_unschedule_event(returning$preuntouched), which wp-cron.php and WP-CLI'swp cron event runpass through just beforedo_action_ref_array(), to note the event about to run. On first sight of a hook it adds a callback atPHP_INT_MINthat starts a run (trigger: "wp-cron",Jobs::TAG, so the trigger and the tag cannot drift) and one atPHP_INT_MAXthat finishes it, keeping what the callbacks echo as the output after any lines from thecronwatch_logaction (the documented form, harmless with the plugin inactive; thecronwatch_log()function, which the plugin has always defined, does the same and is kept without being documented). The echoes go on as they are written, through an output buffer with a handler that flushes every 8 KB and keeps only the last 64 KB (four times the run's cap, so the cap, counted in UTF-16 units, always has enough), so a callback that streams a large export holds no more in memory than it did without the plugin. A hook fired without a matching note (ado_action()by hand) is not a run. - Jobs. A recurring event is
wp:<hook>with scheduleevery <interval>sfrom its recurrence (hourly3600,twicedaily43200,daily86400,weekly604800, custom ones fromcron_schedules); one with arguments iswp:<hook>:<key>, the key the first 8 hex characters of WordPress's ownmd5(serialize($args)), so each set of arguments is its own job. Single events are one job per hook without a schedule: one-offs have no cadence to miss, and the recurring core events (wp_version_checkand the rest, watched like any other) show whether WP-Cron fires at all. A hook with characters outside the job name rules has them turned into-, and one that had to be changed or cut gets-<8 hex of md5(hook)>, so two hooks never collide. Jobs are taggedwp-cron, described by hook, arguments, and recurrence, and adjustable by filters (cronwatch_watch_event,cronwatch_job_options). Options fromcronwatch_job_optionsthat the library refuses (a timeout of2 hours) are reported to the error log and the job declared with its own options instead, in the check and when a run starts, so one filter's bad result never stops the check or the recording of that event's runs. An event gone from the cron array (its plugin deactivated) has its job declared again without a schedule at the next check, so it is never reported missed. - Failures. An uncaught exception, a fatal error, or
exit()in a callback records the run as failed with phase 1's text (Interrupted: Fatal error: <message>and itsfile:line, orInterrupted: the process exited during the run). WordPress's fatal error handler is a shutdown function registered before any plugin's, and it shows its page throughwp_die(), which exits and so stops every later shutdown function; the plugin records its open runs from inside that path (thewp_php_error_messageandwp_die_handlerfilters) and from its own shutdown function when the handler is off or output was already sent. Checked under WP-CLI and under a real wp-cron.php request. - Storage.
WpdbStorekeeps the MySQL dialect's three tables through$wpdb, named with the site's prefix andcronwatch_(capitals allowed, as WordPress allows them in prefixes), each statement with values run as$wpdb->prepare()of the library's text with its?made placeholders (%sfor a string,%dfor an integer; a null isNULL, and a number that is not a safe integer its digits, since%fwould round it), called where the query runs; the table names stay written into the library's text, whichMysqlStoreshares (the constructor checks the prefix against[A-Za-z0-9_]), and the plugin's ownDROP TABLEnames them with%i; the stored bytes areMysqlStore's, which a test checks column by column on one database. The tables are made on activation withCREATE TABLE IF NOT EXISTS, notdbDelta(), which would keep "fixing" the descending index and the expression default;cronwatch_db_versionmarks the schema for later migrations, and a site of a network gets its tables when first used. It needs MySQL 5.7.8 or MariaDB 10.3 (JSON_EXTRACT, and the long index keys utf8mb4 needs); before MySQL 8.0.13 the metrics column goes without itsDEFAULT ('{}'), which changes no stored value since every writer gives metrics. Activation refuses older servers with a message. - The check. The plugin runs it from its own WP-Cron event every five minutes and from
wp cronwatch check(the line every port prints). A check that runs when someone visits cannot notice a site nobody visits, so the readme recommendsDISABLE_WP_CRONand a system crontab callingwp cron event run --due-now(or wp-cron.php) andwp cronwatch check, and a missed run is how a site learns it needs one. - The dashboard. A CronWatch menu in wp-admin (
admin.php?page=cronwatch), behindmanage_options: the library's dashboard, every page of it, served within wp-admin and never at a public URL. The dashboard is a page of its own (its CSS, a strict CSP), so the admin screen shows it in a frame, andadmin.php?page=cronwatch&cw=<path>answers with the dashboard's page at<path>from theload-<hook>action, before wp-admin writes anything; WordPress has checked the login and the capability by then (the menu page asks for it) and the handler checks it again. It runs the dashboard open (token: null, the opt-out, since WordPress is its sign-in, with the request'sAuthorizationheader taken out, as Drupal and Craft do, so a bearer cannot make a GET of/api/checkrun the check) with the site's admin origin for its same-origin check, and every change must also carry a WordPress nonce. The dashboard is given a marker as its base path, and every link, form action, and redirect under it is rewritten toadmin.php?page=cronwatch&cw=<path>(with the nonce on each form's action); its pages' head is the plugin's (the dashboard'shead:option): the stylesheet,css/dashboard.css, registered withwp_register_style, enqueued, and printed withwp_print_styles, as the plugin directory asks of every stylesheet, and no script, since the app shell's manifest and app.js (not served under a host's head) are no use in wp-admin, which is not where the dashboard is installed as an app. The page's CSP is the plugin's: the library's lessscript-src,manifest-src, andworker-src, withstyle-src 'self' 'unsafe-inline'(and the plugins' origin whenWP_PLUGIN_URLputs them on another; the timeline places its marks with style attributes), andX-Frame-Optionsandframe-ancestorsallowing wp-admin to frame it. Its "Run check now" runs the plugin's check (WP-Cron's events declared first). Before the first check the board says that WP-Cron's events appear after it, with the button andwp cronwatch checkto run it now (the dashboard'sempty:option), not the library's line on declaring a job in code. Alert links open the job's page there. - The JSON API. Off by default: nothing is registered, and the site answers 404. The owner turns it on under CronWatch, Settings, with a token (one typed in of at least 24 characters, or one the plugin makes, 40 characters, shown once). It is then the REST route
cronwatch/v1/api/..., only the dashboard's/apipaths. Itspermission_callbackchecksAuthorization: Bearer <token>by the token's SHA-256 against the saved digest withhash_equals(the settings keep onlysha256:<hex>, never the token, and a token an earlier release saved as it was is changed to its digest the first timePlugin::settings()reads it; the dashboard answering the request is then given the token the request carried) (or, for/api/check, against the client's cron secret, when a developer'scronwatch_client_argsgives it one) before anything else runs, so a reviewer sees the check where WordPress looks for it and nothing (not even WP-Cron's events being declared for a check) happens for a caller without the token; a missing or wrong one is aWP_Errorwith status 401 whose body, written by the plugin, is the dashboard's own{"ok":false,"error":"Unauthorized"}with its headers, which@cronwatch/mcpreads, and a request while the API is off is the REST server's 404. The route's path, which WordPress has already decoded once, has each segment encoded again before the dashboard reads it (so%252e%252eis never decoded twice into..), and a.or..segment is a 404, so nothing under the route reaches the dashboard's pages. A token typed in must be at least 24 ofA-Z a-z 0-9 . _ ~ + / = -and its digest is taken of it as typed; anything else is refused, sincesanitize_text_fieldwould have saved something shorter than what the owner gives the client. The request is then answered by the library's dashboard with that token, so the base URL for@cronwatch/mcpisrest_url('cronwatch/v1')(https://example.com/wp-json/cronwatch/v1, orhttps://example.com/?rest_route=/cronwatch/v1without pretty permalinks; the MCP server appends/api/...to it either way). The body is the library's bytes, written inrest_pre_serve_requestrather than encoded again by the REST server (which would escape every/), with the library's status and headers; WordPress's own REST headers (Link, the CORS ones) are still sent. - Multisite. Activated for a network, activation makes the tables and schedules the check on every site;
wp_initialize_site(after WordPress has made a new site's tables) does the same for a site made later while the plugin is network active; network deactivation clears the check from every site;uninstall.phpalready covers every site. - Settings. CronWatch, Settings, behind
manage_optionsand a nonce on every form: email throughwp_mail(a WordPress channel composing the library's email), a Slack URL and a webhook URL and secret (the library's channels, overwp_remote_postwith no redirects and a ten second timeout, as the plugin directory prefers to direct curl), the other channels (Bridge\ChannelSettings, each folded under its name: a key, token, or DSN is a password field never shown again, kept when left blank, and cleared with a box beside it, saved as typed unless it holds a control character; a provider only partly filled in is saved and named in a notice, and sends nothing until it is complete), grace, a test alert, and the watched jobs' health. The site sends requests to the addresses set there, internal ones included on a single site (a multisite network refuses private addresses,cronwatch_reject_unsafe_urls), and the test alert shows what each answered, so the page and the readme say these settings are for people trusted with the server's network, as Drupal's permission does; no guard of its own is added, since one that resolves the host first can be outrun by DNS rebinding. Nothing leaves the site until a channel is set; with none, alerts go to the error log (the library's default channel,Console). The test alert goes where a real one would, every channel of the client (Plugin::client()->alerts), so with none set it is written to the error log and the notice says so. A save that refuses the grace and the token both names both. The page links to the dashboard. Options and events are prefixedcronwatch_;uninstall.phpdrops the tables and removes every option and event, on every site of a network. Developers getcronwatch_alertsandcronwatch_client_args; a channel added there without anhttp:goes throughwp_remote_posttoo (Transport). - Floors.
Requires PHP: 8.2, the library's.Requires at least: 6.5, the first WordPress whose PHP 8.2 support is complete and the one Plugin Check asks for, since the library usesarray_is_list(WordPress polyfills it from 6.5, and PHP 8.2 has it anyway); the hooks used need only 5.7. - Plugin Check. The zip is checked with the directory's Plugin Check (
wp plugin check cronwatch, every category, experimental checks, and low severity results included) in a current WordPress, as the directory checks a submitted zip. The plugin's own code follows the WordPress rules: every output escaped, the settings form's nonce checked where it is read, translator comments on every placeholder, and each direct query annotated with its reason (the store's statements are the library's fixed text, each value a placeholder of$wpdb->prepare(), on the plugin's own tables, which no WordPress API reaches;uninstall.php'sDROP TABLEandWpdbStore::drop()name the tables with%iplaceholders, the prefix still checked against[A-Za-z0-9_]). The library is not WordPress code and runs without it, so it cannot callesc_htmlorwp_parse_url: where Plugin Check reads a library line as WordPress code (aparse_url, the default channel'serror_log) the line carries aphpcs:ignore(or aphpcs:disableandphpcs:enablearound a few lines) naming the rule and why, andbuild.phpadds one line after each library file'sdeclare()that turns off the rule for exception messages, which are plain text for logs, WP-CLI, and the dashboard and are escaped wherever the plugin shows one.Sqlnames no PDO (itsbind()isPdoStore's), andJscalls\array_is_listfully qualified, PHP's own since 8.1, which Plugin Check would otherwise take for WordPress 6.5's polyfill. It reports no errors and no warnings; the one warning it gave before (triage calling Anthropic's API directly, where it suggests WordPress 7.0's AI client) went with triage, which the zip leaves out. - Listing assets. The directory's page takes images from the plugin's SVN
assets/folder, not from the zip, so they live inwordpress/assets/, whichbuild.phpleaves out:banner-772x250.pngandbanner-1544x500.png(the header banner, standard and high DPI),icon-128x128.pngandicon-256x256.png(or oneicon.svg), andscreenshot-1.pngtoscreenshot-4.png, one per line of the readme's Screenshots section, in its order: the dashboard's health and last 24 hours, an event's page, the settings form, and the test alert button with the watched events.make.mjsrenders the icons and banners fromicon.svgandbanner.html(its timeline strip redrawn from the site's captured dashboard); the screenshots are captures of a real WordPress running the built plugin, whichscreenshots/run.shretakes: it starts WordPress and MySQL in Docker, installs the zipbuild.phpmakes with a must-use plugin of the Example Store's events, seeds a week of runs (the successful ones through the steps wp-cron.php takes, each failure a real wp-cron.php request that ends on its uncaught exception), captures the four pages with Playwright, and removes the containers. Only the images andicon.svggo to SVN. - Tests.
tests/WordPress/WordPressTest.phpinstalls WordPress with WP-CLI into a temporary directory against a real MySQL or MariaDB (CRONWATCH_TEST_WORDPRESS, amysql://URL;CRONWATCH_TEST_WPCLI, the phar;CRONWATCH_TEST_WP_VERSION, default the current release, 6.5.13 on CI's PHP 8.2), installs the built zip, and drives it throughwpcommands,wp eval-file, and wp-cron.php, wp-admin (signed in with cookies WordPress made for the test users), and the REST route underphp -S, with a must-use plugin of test callbacks, clock, and channel that also keeps WordPress off the network (its own events request api.wordpress.org, and Site Health requests the site itself, which the one-request-at-a-time server could never answer). A second install, a network, checks network activation, a site made later, network deactivation, and uninstalling. The WordPress test suite (WP_UnitTestCase, as wp-phpunit or wp-env run it) was passed over: it wraps each test in a transaction and turnsCREATE TABLEinto temporary tables, which hides what needs testing here (real tables,uninstall.php, a fatal error that ends the process, a wp-cron.php request), and it trails PHPUnit's current majors.tests/WordPress/PluginTest.phpchecks the naming, the schema per server, and the zip's contents without WordPress.
- Changelog. The readme's changelog is what the directory shows under a release, so a released section is never rewritten:
- Drupal. A module (
drupal/, machine namecronwatch, namespaceDrupal\cronwatch, written to Drupal's coding standards). Drupal 10.3 and newer (10.6 tested; older 10.x minors are out of security support) and 11 (11.4 tested; 11 needs PHP 8.3), with Drush 13; CI tests 10.6 on PHP 8.2 with MySQL, and 11 on 8.3 with SQLite and on 8.5 with Postgres. Drupal 12 is in alpha (PHP 8.5) and waits for its release. The hooks are procedural ones incronwatch.module, which both majors call (11 deprecates onlyhook_module_implements_alterandhook_requirementsin that form, and neither is used).- Watching cron with no code changes. Drupal runs every module's
hook_cronfrom thecronservice (Automated Cron after a response,drush cron,/cron/<key>from a system cron), inCron::invokeCronHandlers(), which calls each throughModuleHandler::invokeAllWith('cron', ...), catches an\Exceptionso one module cannot stop the others, and lets an\Errorend the run.CronwatchServiceProvider::alter()makes the serviceWatchedCron, a subclass of core'sCron, so its constructor and arguments stay core's, whichever core it is (10 declares the service lazy through a proxy generated for core's class, so the module makes it eager, as 11 has it).run()is core's with the whole run recorded around it: started when the cron lock is taken and hook_cron is about to run (a run that finds cron locked records nothing), ended after the queues, failed with what it threw, and followed by the check.invokeCronHandlers()is core's line for line (the same order, the same timing log lines, the same\Exceptioncaught) with each module's hook a run of its own; a module with several implementations (Drupal 11.1's#[Hook('cron')]on more than one method, whichinvokeAllWith()hands over one by one under the same module name) is one run, started before its first and finished after its last (their number counted first with a call toinvokeAllWith()that calls none), failed with the first exception any threw, so a module whose first method fails and second succeeds is one failed run per cron run, not an alert and a recovery each time. A decorator of the service would see only the whole run, and a decorator of the module handler would have to implement an interface that changes between majors, so the subclass, reimplementing one short protected method, is the narrow way in; 10.6's and 11.4's methods are the same. A module whose service provider replaces the class after the module's ownalter()has run (both run in module order, soultimate_croncomes aftercronwatch) would undo it, soCronServicePass, a compiler pass the provider'sregister()adds, runs after everyalter()(and after Drupal 10 has put a lazy service behind its proxy, so it changes the original definition) and says which service the site ended up with in the parametercronwatch.cron_service:core,ultimate_cron(see Ultimate Cron), or none for another module's service, which is left alone, the settings page saying CronWatch cannot record cron there. The Scheduler module's publishing is itshook_cron, watched asdrupal:schedulerlike any other. - Jobs.
drupal:cronis the whole run (trigger"drupal-cron","cron"before 0.11; its output lists the modules that ran and those that failed), anddrupal:<module>each module's hook_cron, with what it logged and the exception it threw. A module's exception fails its own run and nothing else, as core carries on past it; an\Errorfails the module's run and the cron run, which core lets it end. Drupal's cron has no schedule per hook, since every hook runs on every cron run. Giving each module the site's schedule would turn a cron that stopped into one missed alert per module, so onlydrupal:cronhas a schedule: the one under the settings (what the system crontab does, a cron expression orevery 1h), else Automated Cron's interval asevery <interval>s(Automated Cron runs cron after the first response oncesystem.cron_lastplus the interval has passed, which is whateverymeans), else none, and the settings page says which. Drush sets Automated Cron's interval to 0 for its own commands (a settings override), so under Drush the stored value is read. The module jobs report failures, slow runs, and stuck runs. All are taggeddrupal-cron, andhook_cronwatch_job_options_alter()changes any job's options. Options the library refuses (an alter hook's, a worker's#[Watch], a schedule imported with the configuration that the form never validated) are reported to the log, and the job is declared with fewer: the module's own options without the alter hooks, then those without their schedule. So one bad job never stopsprepare()declaring the rest, the check, or its own runs being recorded (drupal:cronwithout its schedule is still recorded). Jobs of modules uninstalled have no schedule and are never missed; adrupal:cronschedule taken away is dropped at the next declaration. - Ultimate Cron. Read against Ultimate Cron 8.x-2.0-beta1 (
drupal/ultimate_cron2.0.0-beta1, the current release, for Drupal 9.3 to 11). It replaces thecronservice withUltimateCron(a subclass of core'sCron), whoserun()loads its enabled jobs (ultimate_cron_jobconfig entities: one per module'shook_cron, made when a module is installed, id<module>_cron, callback<module>#cron; queue workers as jobs when its queue handling is on; any a site adds) and hands each to its launcher (SerialLauncher), which runs those its scheduler says are due.CronJob::run()takes the job's lock (a job locked, or still running elsewhere, is not run at all, and the launcher does not even ask), opens its log entry, invokeshook_cron_pre_run, callsinvokeCallback(), catches an\Exceptionor\Errorinto the log entry at error level, then closes the entry, invokeshook_cron_post_run, and lets the lock go. Its hooks were weighed and passed over:hook_cron_post_runcomes after the exception has been caught, flattened into the log entry's markup and the job'srun()has returnedFALSEto no one, so a failure could only be guessed from the log entry's severity, which a job that merely logs an error also raises; its loggers (Database and Cache, chosen per job) would mean taking over or wrapping the site's choice of logger to see the same flattened text. So the module does what it does for core:hook_entity_type_alter()makes the job entityUltimateCron\WatchedCronJob, a subclass ofCronJob(only while the class is still Ultimate Cron's own), whoseinvokeCallback(), the one protected method every run goes through whoever launched it (a cron run, the job's "Run" button,drush cron:run <job>), records the run around Ultimate Cron's: started before the callback (soCronwatch::current()is its context), finished after it, failed with what it threw, which is thrown on for Ultimate Cron to catch and log as before.CronServicePassmakes the serviceUltimateCron\WatchedUltimateCron, a subclass whoserun()is Ultimate Cron's with the whole run recorded asdrupal:cron(Ultimate Cron takes no lock around it, so neither does this; its output lists the jobs launched,jobs: ..., and those that failed) and the check after it. Entity classes are swapped throughsetClass(), which keeps the original as a decorated class, soCronJob::load()still finds the type.- Names. A module's
hook_cronjob (id<module>_cron, callback<module>#cron, or<module>_cronbefore Ultimate Cron's update to that form) isdrupal:<module>, as under core, so its history goes on when Ultimate Cron is installed or removed; any other job isdrupal:job:<id>. The trigger is"ultimate-cron"for a run a cron run launched and"ultimate-cron-manual"for one launched on its own;drupal:cronkeeps"drupal-cron". Tags aredrupal-cronas under core, andhook_cronwatch_job_options_alter()seeskindultimate_cronwithjobandmodule. - Schedules. Each job has its own: its scheduler plugin's rules (Simple's presets are Crontab rules:
*/15+@ * * * *and the like). A rule is a crontab line with+N, an offset added to each value a range or step names, and@, the job's skew (hexdec(substr(sha1(id), -8)) & 0xff, fixed per job), taken modulo the field's size.UltimateCron\Rulesasks Ultimate Cron's own parser (CronRule::getIntervals()) for each field's values at the job's skew, drops the days and months it never matches (an offset can wrap to 0), and writes them out as a plain cron expression (*,a-b,*/nwhere those say it, else a list), so*/15+@for a job whose skew is 7 is7,22,37,52 * * * *: the reading is Ultimate Cron's, value for value. Ultimate Cron matches day of month and day of week either-or only when it counts both as narrowed, and counts the values as given; that is said with both fields written out (croner's rule), or with*where one side is everything. What a cron expression cannot say leaves the job without a schedule, reported to the log once per process ("Ultimate Cron job : , so the job is watched without a schedule"): another scheduler plugin, several rules naming different times, a rule Ultimate Cron reads both-and with both fields narrowed (only offsets that wrap past the month's end give one), a rule that never matches or one Ultimate Cron cannot read. The zone is the site's default (system.date), the one Drupal sets PHP's to for cron, in which Ultimate Cron reads its rules. A disabled job has no schedule. Ultimate Cron runs a job at the first cron run after its time, so the jobs are only on time when cron itself runs at least as often as the most frequent of them (Ultimate Cron's install notes ask for every minute); with Automated Cron's three hours, a job every 15 minutes is reported missed, which is what it is. Thecatch_upsetting (skip a run that would come more than N seconds late) is not read: a run skipped that way is a missed run. drupal:cron. Still the whole cron run, on the same schedule as under core (the settings, else Automated Cron's interval): with every job on its own schedule, a cron that stops is reported by each job as its time passes, and bydrupal:cron, which is the one that says why. Under Ultimate Cron a cron run is the moment jobs are launched, not a sequence of hooks, so the drawback that kept the modules unscheduled under core (one alert per module for one stopped cron) is the price of each job's own schedule here.- Skipped runs. A job Ultimate Cron does not run, because it is locked, still running, disabled, or not due, records nothing: its lock is taken before
invokeCallback(), and a run that never started is not a run (as a Laravel task skipped for overlapping). One still running is the run already recorded, and stuck covers it. - Queues. With Ultimate Cron's queue handling on, each queue is a job (
drupal:job:ultimate_cron_queue_<worker>) and each item of a watched worker is still a run ofdrupal:queue:<worker>, since Ultimate Cron makes workers through the plugin manager; with it off, core's queue processing runs at the end of the cron run, as before. - Not supported. A launcher that runs a job in another request (none ships with Ultimate Cron for Drupal 8 and newer, whose one launcher is Serial): the run is recorded wherever
invokeCallback()runs, with the triggerultimate-cron-manualthere. A module that swaps the job entity's class too is left alone, and its jobs' runs are not recorded. Ultimate Cron's own log, thecatch_upsetting, and its lock timeouts are not read.
- Names. A module's
- Queues. Queue workers run in cron (those whose definition has a
crontime) or fromdrush queue:run. The owner picks workers under the settings, or a worker class carries#[Cronwatch\Watch](its options,nameincluded;enabled: falseleaves it out).hook_queue_info_alter()putsWatchedQueueWorkerin a watched worker's definition, keeping its class; that makes the real worker as the plugin system would (through itscreate()when it has one) and records eachprocessItem()as a run ofdrupal:queue:<worker>(trigger"drupal-queue","queue"before 0.11), in cron, Drush, or anywhere else. What the worker throws, a failure or aRequeueException,DelayedRequeueException, orSuspendQueueExceptionasking for the item back, is recorded as a failed attempt and thrown on, so the runner releases, delays, or keeps the item as it would: failing attempts open one alert and the attempt that succeeds closes it, the retry rule of the other ports. Workers are taggeddrupal-queue; one no longer watched is declared again without its schedule (Bridge\Unscheduled). - The check. At the end of each cron run this process ran (
check_on_cron, on by default), and fromdrush cronwatch:check(the line every port prints; an error is a non-zero exit), each after declaring every job (Recorder::prepare()). A check that runs at the end of cron cannot notice cron not running: when nothing starts cron (a quiet site on Automated Cron, a crontab line that broke), nothing starts the check. So the README, the settings form, andhook_help()recommend the check from the server's crontab, separately, and a system cron over Automated Cron for anything that must run on time; a misseddrupal:cronis how a site learns it needs one. - The store.
Storagereads the connectionsettings.phpdescribes ($databases['default']['default'], or the key$settings['cronwatch_database']names): MySQL and MariaDB (themysqldriver, and 11'smysqli), Postgres, or SQLite, and opens a connection of its own with its host, port, database, credentials, socket, and PDO attributes (SSL), so a run recorded inside Drupal's transaction survives its rollback. A SQLite path is resolved from Drupal's root, as Drupal opens it (11 keeps it relative, 10 writes it absolute). The library's SQLite store puts the file in WAL mode (as it does any file it opens), which Drupal's own connection works with, and waits up to five seconds for a write Drupal holds. The tables are the site's table prefix, lowercased, andcronwatch_(on a SQLite site with a prefix, which Drupal keeps in an attached file, they are in the main file).hook_install()makes them with the store's owninit(), so the CREATE text is every port's (IF NOT EXISTS);hook_uninstall()drops them. Another driver is refused at install with a message. - Channels and settings.
cronwatch.settings(a config object with a schema) holds the email addresses, the Slack URL, the webhook URL and secret, the other channels' settings (Bridge\ChannelSettings, one key each,resend_api_keysay, added to an existing site bycronwatch_update_10001), the grace (default 10m), the schedule,check_on_cron, and the watched queues. Email goes through Drupal's mail system:MailChannelasks the mail manager, andhook_mail()puts the library's subject and plain text in the message, which the mail system formats as it formats any plain text mail. Slack and the webhook are the library's channels. The form (Configuration, System, CronWatch, aConfigFormBasewith Drupal's form token) validates the grace, schedule, and URLs and refuses a channel only partly filled in, clears the queue definitions when the list changes, and sends a test alert (Bridge\TestAlert). With no channel, alerts go to Drupal's log (LogChannel, thecronwatchchannel, which is alsoonError);hook_cronwatch_alerts_alter()adds channels. Configuration is exported with the site's, so the README shows credentials set insettings.php. - Permissions and the dashboard.
administer cronwatch(the settings, and changes from the dashboard) andview cronwatch dashboard, both restricted. Reports, CronWatch (/admin/reports/cronwatch) is an admin page with the dashboard in a frame (the dashboard is a page of its own, with its own CSS and strict CSP);/admin/reports/cronwatch/view?cw=<path>is the dashboard's page at<path>, behind the view permission, served open (token: null, Drupal's sign-in standing for the token) with the request's origin for the dashboard's same-origin check. A change (a POST: silence, forget, "Run check now") also needsadminister cronwatchand Drupal's CSRF token, which each form's action carries. A Drupal route parameter cannot hold slashes without a path processor, so the dashboard's path rides in?cw=, as in wp-admin:Bridge\EmbeddedDashboardgives the dashboard a marker as its base path and rewrites every link, form, and redirect under it to the route's URL (made by Drupal's URL generator, so a site in a subdirectory or behind a language prefix works), adds the token to each form's action, leaves the app shell out, and lets the admin page frame it. The routes are markedno_cache. The JSON API for@cronwatch/mcpis/cronwatch/api/...(a route with optional parameters, since the API's paths have at most four segments), open to the router but 404 until a token is set ($settings['cronwatch_token'], elseCRONWATCH_TOKEN), then answered by the dashboard with that token. A POST to/api/checkdeclares every job first (prepare()), and only for a request that carries the token (or the client's cron secret) as a bearer, or the dashboard's cookie: anyone else gets the dashboard's 401 having made no alter hook, configuration read, or read of the jobs table run. Alert links point at the dashboard's page for the job, from$settings['cronwatch_base_url']when set; else from the request, but only where its host is known good (under Drush, its--uri; with$settings['trusted_host_patterns'], which Drupal checks every host against). On a site that trusts any host, Automated Cron runs after a visitor's request whoseHostthe visitor chose, so there the alert goes without a link rather than with one to that host. - Tests.
tests/Drupal/DrupalTest.phpmakes a Drupal project with Composer (drupal/core-recommendedatCRONWATCH_TEST_DRUPAL, Drush, and the library and the module from this checkout as symlinked path repositories), installs a site withdrush site:install minimalon SQLite, MySQL, or Postgres (CRONWATCH_TEST_DRUPAL_DB), enables the module, Automated Cron, andfixtures/cwt_fixtures(a hook_cron that logs, throws, or throws an\Erroras state says, two queue workers, and an alert channel that keeps what it is sent), and drives it:drush cron(each hook a run, a failing hook failing its own run only, an\Errorfailing the cron run), the schedule from the settings, from Automated Cron, and none,drush cronwatch:checkreporting cron missed and the next run recovering, queue items retried until they succeed (each attempt a run, one alert and its recovery, a worker marked#[Watch], cron's own processing, a worker taken off the list), the admin pages under PHP's built-in server signed in with one-time login links (the frame, the rewritten links, a change refused without the token or the permission, a viewer, an anonymous visitor, the settings form),/cron/<key>and Automated Cron after a page, the JSON API with and without a token, then Ultimate Cron (2.0.0-beta1, a dependency of the test project only: a cron run and each job recorded with its own schedule and zone, a job run on its own, a failing job and an\Error, a locked job that is no run, a job reported missed by the check with its runs moved three hours back, rules read as Ultimate Cron reads them, an unconvertible rule declared without a schedule and reported once, a disabled job, and core's recording back once Ultimate Cron is uninstalled), and uninstalling. Drupal's own test bases were passed over:KernelTestBaseandBrowserTestBaseneed core's development dependencies and its PHPUnit, install each test's site under a random prefix (SQLite prefixes as attached files) and run cron inside the test's process, which hides what needs testing here: the library's own connection to the site's real tables, a cron run in a process of its own, Drush, and the web routes.
- Watching cron with no code changes. Drupal runs every module's
- Craft CMS. A plugin (
craft/, handlecronwatch,Cronwatch\Craft\Plugin). Craft 5.3 and newer (5 is the current major; 6 is in alpha), PHP 8.2 or newer, on MySQL, MariaDB, or Postgres, the databases Craft runs on; CI tests 5.3 on PHP 8.2 with MariaDB, and the newest 5.x on 8.2 with MySQL and on 8.5 with Postgres.- What runs. Craft has no scheduler: a site's scheduled work is console commands the host's crontab runs (
craft resave/entries, a module's own commands) and queue jobs (Craft's queue, run bycraft queue/runorqueue/listen, or by the runner a Control Panel request starts). Scheduler plugins exist, but none is common enough to map, and their work is a console command or a queue job, which this watches. - Queue jobs. Watched when they opt in: the class is listed in the settings'
queueJobs(fromconfig/cronwatch.php, with its job options) or carries#[Cronwatch\Watch]. Watching every job was weighed and left out: Craft queues its own jobs on every save (search indexes, resaving elements, image transforms), with nothing to be missed and thousands of runs a day, which would bury the jobs that matter. The plugin listens to yii2-queue's class-level events, so every queue is covered:EVENT_BEFORE_EXECstarts the attempt's run (trigger"craft-queue","queue"before 0.11, idcraft-queue:<job id>:<attempt>:<random>;Cronwatch::current()is its context),EVENT_AFTER_EXECends it ok, andEVENT_AFTER_ERRORends it failed with the error, whether the queue retries the job (aRetryableJobInterfacejob'scanRetry(), or the queue's attempts) or gives up. So failing attempts open one alert and the attempt that succeeds closes it. A job is named after its class, backslashes as dots, unless its options name it. - Console commands. A command runs as a Yii console action, so the plugin listens to
yii\console\Controller's class-levelEVENT_BEFORE_ACTIONandEVENT_AFTER_ACTION: a route the settings'commandslist ('resave/entries' => ['schedule' => '0 3 * * *'], ortruefor runs without a schedule) is a run ofcraft:<route>(slashes as colons:craft:resave:entries) unless the options name it, trigger"craft-command"("command"before 0.11), ended ok, or failed withExited with code Nfor a non-zero exit. An exception ends a command beforeEVENT_AFTER_ACTION, so the console error handler'sEVENT_BEFORE_HANDLE_EXCEPTIONends every open command run failed with it. A watched command that runs another ($this->run()) and catches what that one threw reaches its ownEVENT_AFTER_ACTIONwith the inner run still open and the error handler never called: each run is kept with its action, andEVENT_AFTER_ACTIONfinds its own from the innermost out, failing the runs opened after it (Threw an exception that craft <route>, which ran it, caught) before finishing it. A command the app writes can carry its options in code with theWatchCommandbehavior (actionslimits it to some actions); the settings win for a route both name. A command's output goes to the terminal throughstdout(), which PHP cannot read back, so a run's output is what it logged throughCronwatch::current(). - Schedules. What the settings (or the behavior, or
#[Watch]) say the crontab does, so a command the crontab stopped running is reported missed. The check declares the settings' commands and queue jobs (taggedcraft-configas well) and declares again without its schedule any such job the settings no longer list (Bridge\Unscheduled). A behavior's job is declared only when its command runs, so a behavior removed leaves its job's schedule behind: the plugin's docblock says to forget it from the dashboard. - The check.
craft cronwatch/check, from the host's crontab every five minutes, prints the line every port prints (an error is one line on standard error and exit status 1). Craft has nothing to run it on a timer, and a check queued as a job would stop with the queue it watches. - The store.
Storagereads Craft's database connection (its DSN, credentials, PDO attributes, and Postgres's schema as asearch_path) and opens the library's MySQL or Postgres store on a connection of its own, with the MySQL DSN's charset taken out so the store'sutf8mb4applies. The tables are Craft's table prefix, lowercased, andcronwatch_. The plugin's install migration (migrations/Install.php) makes them with the store'sinit(), and itssafeDown(), which Craft runs when the plugin is uninstalled, drops them. - Settings and channels. A settings model (
models/Settings):emailTo(through Craft's mailer, with the library's subject, text, and HTML),slackWebhookUrl,webhookUrl,webhookSecret, the other channels' settings (Bridge\ChannelSettingsin camel case,resendApiKeysay),grace,apiToken, and, fromconfig/cronwatch.phponly,commandsandqueueJobs. Each string may name an environment variable ($SLACK_WEBHOOK_URL, read withApp::parseEnv()), so credentials stay out of project config, andconfig/cronwatch.phpwins over the form, whose fields it sets are shown disabled. The Control Panel's form (Craft's own, with its CSRF token) validates the grace and the URLs and refuses a channel only partly filled in; its "Send a test alert" (controllers/SettingsController, for admins in the Control Panel, a POST with the CSRF token) sendsBridge\TestAlertto every channel of the saved settings and shows what each answered. With no channel, alerts go to Craft's log (categorycronwatch, alsoonError);Plugin::EVENT_ALERTS(anAlertsEvent) adds channels. Craft keeps plugin settings in project config, so a production site that disallows admin changes sets them inconfig/cronwatch.php, as Craft recommends for any plugin. - Permissions and the dashboard. A Control Panel section (
hasCpSection), CronWatch, atadmin/cronwatch: a page with the dashboard in a frame, andadmin/cronwatch/view?cw=<path>the dashboard's page, both behindaccessPlugin-cronwatch(the permission Craft gives a plugin's section), served open (token false) with the request's host as the origin. A change also needs the plugin'scronwatch-managepermission, and Craft's CSRF validation, on for every Control Panel POST, needs its token, whichEmbeddedDashboardputs in every form as a hidden field. The JSON API is a site URL rule,cronwatch/api/<path>, anonymous and without Craft's CSRF check, 404 untilapiTokenorCRONWATCH_TOKENis set, then answered by the dashboard with that token. A POST to/api/checkdeclares the settings' jobs first (prepare(), which reads the jobs table), and only for a request that carries the token (or the client's cron secret) as a bearer, or the dashboard's cookie; anyone else gets the dashboard's 401, even with the store down. Alert links are Control Panel URLs (UrlHelper::cpUrl()), frombaseCpUrlwhen it is set, else from@web. When no config sets@webCraft takes it from each request, and in a web request (a queue job the Control Panel's queue runner runs) that is theHosta visitor sent, so there the host is the primary site's configured URL, and a site whose URL is@webitself sends alerts without a link. - Tests.
tests/Craft/CraftTest.phpmakes a Craft project with Composer (craftcms/cmsatCRONWATCH_TEST_CRAFT, and the library and the plugin from this checkout), writes the files a new Craft project has (the bootstrap,craft,web/index.php, a router for PHP's built-in server,config/general.php,config/app.phploadingfixtures/cwt, and aconfig/cronwatch.php), runscraft installon MySQL, MariaDB, or Postgres (CRONWATCH_TEST_CRAFT_DB), installs the plugin, and switches to the Pro edition (which a development install may try) for a second user with the plugin's permission only. It then drives it: listed commands (ok, a non-zero exit, an exception), a command with the behavior, a command nobody listed, a queue job retried until it succeeds (each attempt a run, one alert and its recovery), a job marked#[Watch],craft cronwatch/check, a command taken out of the settings losing its schedule, the Control Panel under PHP's built-in server signed in through Craft's login action (the frame, the rewritten page, the CSRF field, a change refused without the token or the permission, an anonymous visitor), the JSON API with and without a token, and uninstalling. Craft's Codeception framework was passed over: it runs each test in one application instance, inside a transaction, with fixtures installing the plugin, which hides what needs testing here: the library's own connection to Craft's tables, a command or a queue attempt in a process of its own, the error handler ending a command, and the Control Panel's routes, CSRF, and permissions.
- What runs. Craft has no scheduler: a site's scheduled work is console commands the host's crontab runs (
- Test projects. Both suites make their project with Composer and keep it in the system's temporary directory between runs, one per constraint and PHP version, and tell Composer not to block versions with security advisories (Composer 2.9 and newer do by default), since the version under test is the test's choice (when this was written, every Craft 5 release had one).
The owner set this order on 2026-09-28, moving WordPress up to phase 2: WP-Cron fires only when someone visits the site, so missed runs are more common there than anywhere else CronWatch watches.
- Done: the design, the core (durations, schedules with a port of croner, evaluation, stats, output capping and redaction, formatting, serialization, the client with runs, handles, sources, recordRun, deferred delivery, and triage hooks), the memory, SQLite, and MySQL (and MariaDB) stores, the conformance replay, the SDK's client and store tests, and the finish-once tests with real processes.
- Done: the Postgres store (
pdo_pgsql), every alert channel (curl when loaded, PHP's streams otherwise, no redirects followed, one ten second deadline), Claude triage over plain HTTP, the pg_cron source,vendor/bin/cronwatch, the WordPress plugin (tested against a real WordPress, built into a zip for the plugin directory), and the split repository workflow for Packagist, which now publishes each release tag. - Done: the web dashboard and JSON API (one handler;
serve()from the superglobals for a bare script, PSR-7 and PSR-15 for the rest), matchingpackages/ruby/test/web/golden.jsonbyte for byte; the environment rule; the MCP server's end to end test against it; and in WordPress, the dashboard in wp-admin, the JSON API behind an owner's token, network activation, and a zip with no code the plugin never runs. - Done: Laravel (a service provider found by package discovery: every scheduled task watched through the scheduler's events, the
->cronwatch()macro, queued jobs that opt in,cronwatch:checkscheduled by the provider, the store in the app's database through its own connection and its migration, the dashboard behind theviewCronwatchgate) and Symfony (a bundle: Scheduler messages watched through its events, Messenger messages that opt in,cronwatch:checkand a schedule for it, the dashboard's routes behind the app's security), tested on Laravel 12 and 13 and Symfony 6.4, 7.4, and 8.1;handler()with its adapters (the superglobals, PSR-15, Laravel routes, Symfony controllers); and an HTTP response of 400 or more failing a run. - Done: Drupal (a module: every cron run and each module's hook_cron recorded through a subclass of core's cron service, the site's schedule on the cron run alone, Ultimate Cron's jobs each on their own schedule through a subclass of its job entity, queue workers that opt in, the check after each cron run and from
drush cronwatch:check, the store in the site's database through its own connection, the settings form, and the dashboard under Reports behind the module's permissions) and Craft (a plugin: queue jobs and console commands that opt in,craft cronwatch/check, the store in Craft's database through its install migration, plugin settings, and the dashboard as a Control Panel section behind the plugin's permissions); tested in a real Drupal 10.6 and 11 and a real Craft 5.3 and newest 5.x, each made with Composer and driven from the outside.
The first Packagist release can follow phase 3, so the first release people install has a dashboard; nothing is published before then. The plugin's zip is built and checked from phase 2 on; when it is first submitted to the plugin directory is the owner's call.
src/Cronwatch.php's VERSION carries the release version, bumped by scripts/release.mjs with the others. Packagist reads a package's composer.json from the root of a repository and its versions from the repository's tags, so a package in a subdirectory of a monorepo is published through a read-only split repository, as Symfony and Laravel publish their components: .github/workflows/php-split.yml runs on each release tag (v*, which release.mjs makes and the owner pushes), checks that Cronwatch::VERSION is the tag's version, takes git subtree split --prefix=packages/php (the same commits for the same history every time, so each push fast-forwards), and pushes it to the split repository's main with the tag. Packagist watches that repository. packages/php/.gitattributes keeps the tests, the PHPUnit configuration, and the WordPress plugin's source out of the archive Composer downloads. release.mjs still never publishes; it prints that the tag carries the release.
The workflow does nothing until it is switched on. The owner's steps, once, before the first release (after phase 3):
- Create the split repository, empty, public, read-only by convention (
cronwatchdev/cronwatch-php, say), with its description pointing back to this monorepo for issues and pull requests. - Make an SSH key pair (
ssh-keygen -t ed25519 -N "" -f cronwatch-php-split). Add the public half to the split repository as a deploy key with write access (Settings, Deploy keys), and the private half to this repository as the secretPHP_SPLIT_DEPLOY_KEY(Settings, Secrets and variables, Actions). A deploy key reaches that one repository only, where a personal token would reach every repository the account can. - Set this repository's variables
PHP_SPLIT_REPOSITORYtocronwatchdev/cronwatch-phpandPHP_SPLIT_ENABLEDtotrue. - Run the workflow by hand for the current release tag (Actions, PHP split, Run workflow) so the split repository has a
mainand a tag. - On packagist.org, sign in with GitHub, submit the split repository's URL (the package name comes from its
composer.json:cronwatch/cronwatch), and let Packagist's GitHub integration install its hook, so each pushed tag appears as a version within a minute. Add a second maintainer if there is one.
From then on a release is: npm run release -- <version>, then git push origin main v<version>; the split and Packagist follow. composer.lock is not committed, as the gem's Gemfile.lock and the Python package's uv.lock are not: a library's lock file is ignored by the apps that install it, and CI resolves the newest dev dependencies each PHP version allows (PHPUnit 11 on 8.2, 12 on newer).
The WordPress plugin is in the plugin directory, which serves it from its SVN repository (https://plugins.svn.wordpress.org/cronwatch). The same tag starts .github/workflows/wordpress-zip.yml, in two jobs. attach needs no variable or secret (it writes only to this repository's releases, with the workflow's own token): it checks that the plugin's Version is the tag's, runs php wordpress/build.php, makes the tag's GitHub release when there is none (with short notes, marked a prerelease for a prerelease version, so releases/latest stays on the newest stable one), and uploads the zip twice, as cronwatch-X.Y.Z.zip and as cronwatch.zip, replacing either on a rerun, so https://github.com/cronwatchdev/cronwatch/releases/latest/download/cronwatch.zip always serves the newest plugin to a site that installs from a file. svn, once WPORG_ENABLED is true, builds the same zip and commits it to SVN: trunk/ becomes the build, tags/X.Y.Z a copy of it, and assets/ the banners, icons, and screenshots in wordpress/assets, with the SVN_USERNAME and SVN_PASSWORD secrets of the wordpress-org environment. It skips a prerelease and a tag already in SVN. The docs, the setup prompt, llms.txt, the README, and the skill send people to the directory.
Each is released with the library, at the library's version: release.mjs bumps the cronwatch/cronwatch constraint in drupal/composer.json and craft/composer.json to ^X.Y.Z, so a module or plugin release always installs the library it was tested with. Neither file carries a version of its own: drupal.org stamps a release's version into the module's .info.yml when it packages it, and Packagist takes versions from tags. Each reaches its host through a read-only split, as the library does, by .github/workflows/php-plugins-split.yml on the same release tag, each half off until its variable is true:
- Drupal. drupal.org hosts a module's code in its own GitLab (
git.drupalcode.org/project/cronwatch), builds releases from its tags, and serves them to Composer asdrupal/cronwatchfrompackages.drupal.org/8, reading the module'scomposer.jsonfor its requirements, socronwatch/cronwatchcomes from Packagist and must be published first. The workflow pushesgit subtree split --prefix=packages/php/drupalto the branchX.Y.xand the tagX.Y.Z(drupal.org's semantic version names, without thev), after checking that the module requires the tag's library. - Craft. The Plugin Store lists a plugin from its Packagist package and its repository, which must have
composer.jsonat the root, so the workflow pushesgit subtree split --prefix=packages/php/craftto its own GitHub repository'smainwith the tagvX.Y.Z, and Packagist publishescronwatch/craftfrom it.
The owner's steps, once, before the first release of each (nothing here creates a repository or an account):
- Drupal: sign in to drupal.org (a Git username and the Git access agreement are needed to push), create the project "CronWatch" with the short name
cronwatch(a full project, module type), and add an SSH key for pushing: either a deploy key on the project's GitLab repository with write access, if drupal.org's GitLab offers one to the project's maintainers, or the key of a maintainer account made for releases (a personal key reaches every project that account maintains, so keep that account to this project). Put the private half in this repository's secretDRUPAL_SPLIT_SSH_KEY, set the variablesDRUPAL_SPLIT_REPOSITORYtogit@git.drupal.org:project/cronwatch.gitandDRUPAL_SPLIT_ENABLEDtotrue, and run the workflow by hand for the current tag. On drupal.org, make a release from the pushedX.Y.Ztag (Releases, Add new release), mark the branchX.Y.xsupported and recommended, and fill in the project page (the README's text). Security advisory coverage is opt-in and needs a review by the security team; apply for it once the module has had a stable release. - Craft: create an empty public GitHub repository, read-only by convention (
cronwatchdev/cronwatch-craft), with a description pointing back here for issues and pull requests; add a deploy key's public half with write access, put its private half in the secretCRAFT_SPLIT_DEPLOY_KEY, setCRAFT_SPLIT_REPOSITORYandCRAFT_SPLIT_ENABLED, and run the workflow by hand for the current tag. Submit the repository to Packagist (cronwatch/craft). In Craft Console (console.craftcms.com), create a developer account, add the plugin from its repository (free, MIT), and fill in its Plugin Store page (the README's text, an icon, screenshots of the dashboard and the settings).
From then on a release is the library's: npm run release -- <version>, git push origin main v<version>, then make the drupal.org release from the new tag (drupal.org does not make one from a pushed tag by itself). Packagist and the Plugin Store follow the pushed tags.
- Without a
timezone, a cron is read in PHP's default zone (date_default_timezone_get(), fromdate.timezone, usually UTC), which is what "local time" means in PHP, where the SDK reads the process'sTZ. Zone names are matched without regard to case against PHP's zone database, asIntlmatches them; PHP's abbreviations and offsets (EST,+02:00), whichnew DateTimeZone()takes, are refused unless they are IANA names. - Channels and triage run one after another, not at once, and PHP cannot abandon a call that hangs: a channel's timeout is its HTTP request's (ten seconds, as the SDK's
fetchhas), and triage gets a signal with its 25 second deadline rather than being cut off at it; the triage request's own deadline is what the signal has left. A diagnosis that arrives late is used. The SDK's tests of a hung channel and of an aborted triage have no PHP counterpart. - Twilio texts its numbers one after another, in the order given, where the SDK texts them at once; each request has its own ten second deadline, so three unreachable numbers can hold a check for thirty seconds.
- Triage sends the official Anthropic client's request without that client: the same URL,
anthropic-version,anthropic-beta,x-api-key, and body, but its own user agent (cronwatch-php/<version>) and none of the client'sx-stainless-*telemetry headers. The key comes fromapiKeyorANTHROPIC_API_KEY; the client'sant auth loginprofiles are not read. A refused request's error is CronWatch's (Anthropic https://api.anthropic.com answered 529: ...), not the client's error classes. - A channel's name in its constructor's error is its class (
Resend needs an apiKey) where the SDK names its function (resend() needs an apiKey). - Postgres gives a JSON column's object keys back in JSONB's order (by length, then bytes), as it does to the SDK; the store contract test compares key order for every store but Postgres. A pg_cron run id is recognised as up to fifteen digits, where the SDK's
Number()would also read""as 0 (only a hand-made id tells them apart), and pg_cron'soptionsdrop ascheduleortimezonegiven in them, where the SDK's types forbid them. - No
start()/stop()interval: see One thing at a time. A check called from inside a check throws rather than waiting for itself.close()called during a check (by a channel, a source, or triage) cannot wait in one thread, so it closes the store once the check ends. - Two starts with one id in one process are one after the other in PHP, so the second finds the first's stored run and gets a handle of its own on it, where the SDK hands both callers the same handle.
- A handler function's response is any of the kinds
Web\ResponseStatusreads rather than one fetchResponse, and its reason phrase is the one the object carries (PSR-7 and HttpFoundation fill one in for every status, so a Symfony or Nyholm 503 fails a run withHTTP 503 Service Unavailablewhere a fetchResponsemade without astatusTextgivesHTTP 503). Its answer through HttpFoundation (a Laravel route, a Symfony controller) carriesCache-Control: no-store, private: HttpFoundation addsprivateto any Cache-Control withoutpublicorprivate. The dashboard mounted in Laravel or Symfony is served the same way, so its headers are HttpFoundation's rendering of the SDK's (aSet-Cookiewritten by Symfony's cookie class,privateadded to Cache-Control); its bodies are the SDK's. - A development token is kept in a file between requests and announced once, when it is made, where the SDK makes one per process and announces it on the process's first request.
SqliteStorewithout a path usesdata/cronwatch.dbin the app's root (Composer's root package), where the SDK's./data/cronwatch.dbis the working directory's: under PHP-FPM or mod_php the working directory is the script's, oftenpublic/, so the dashboard and the cron check would keep separate files and one could be served.- A multipart form is read from
$_POSTand$_FILESunderserve(), since PHP reads the body itself; PHP turns.and spaces in field names into_and readsa[b]as an array there. The dashboard's own forms are URL encoded and read from the body asURLSearchParamsreads it. - Under PSR-7 without the request target in the server parameters, a
%that starts no escape reaches the dashboard as%25(the URI escapes it), so a path the SDK answers 400 is a 404 there. - In WordPress the dashboard's links point into wp-admin, its pages' head is the plugin's enqueued stylesheet and its app shell is not served (see Frameworks); the JSON API's answers carry WordPress's REST headers too.
- A queued alert is read into
Alert, so one inundeliveredthat lacks a field this release knows, or holds one of the wrong kind (a foreign or hand-edited row), is written back with that field at its default ("title":"","run":null), where the SDK keeps the object as it was stored; its unknown fields are kept. Asendingentry is kept as stored until it is released; then its alert joinsundeliveredthe same way, and an entry whosealertis a JSON array is dropped, where the SDK queues the array. - A webhook URL with a space inside is refused, as the gem refuses it, where fetch would escape the space and post.
expectpatterns are PCRE, not JavaScript regular expressions, and are marked byPattern; one written with/delimiters is stored as written (matches /wrote \d+ files/i), with only JavaScript's flags kept in the description. When PCRE gives up rather than finding no match (its backtrack limit), the failure says why:Output did not match /x/ (Backtrack limit exhausted), where JavaScript would keep matching. That limit (pcre.backtrack_limit, a million by default) is the port's bound on a pattern that backtracks without end, such as stars back to back (/\n*\n*\n*\n*\n*[xy]/) over a long output it does not match, which it stops in milliseconds where V8 takes seconds over a hundred newlines. A pattern stopped by it fails the run, the rule every bounded port keeps (Rust's step budget, Ruby's timeout): a pattern is slow because it is searching an output it does not match, so failing is the answer an engine that finished gives in nearly every case, and a guard that passed when it could not tell would hide the broken job it is there to catch. The other ports fail it with the ordinary message; PHP adds PCRE's reason, as it did before the rule, so the cause is on the run.- A
Throwablepassed tofinish()on its own is a failure, where the SDK treats any object without anerrorkey as a result. - Custom stores implement
Store, and the conditional writes as interfaces of their own (UpdatesRunIf,ComparesAndSetsState), where the SDK checks for the methods.DeletesRunIfhas no SDK counterpart: it takes back a Laravel queued job's released attempt (see Frameworks). - In Drupal, a module's
hook_cronhas no schedule of its own (Drupal runs every hook on every cron run), so onlydrupal:croncan be missed; a module's job reports failures, slow runs, and stuck runs. Automated Cron's interval is read asevery <interval>s, which is when Automated Cron would run, not when a quiet site gets the request that runs it. A site whose cron service another module replaced (other than Ultimate Cron) has no cron runs recorded. Under Ultimate Cron, a job is on time only when cron runs at least as often as its rules; a rule no cron expression says leaves its job without a schedule. - In Drupal and Craft the dashboard lives in the admin, its path in a query parameter, and its app shell left out, as in WordPress; its answers carry the host's headers too (Drupal's and Craft's own, and PHP's built-in
X-Powered-Bywhere the server sends it). - A Craft console command's output is not recorded (it goes to the terminal through
stdout()); its run's output is what it logged throughCronwatch::current(). A command watched only by its behavior keeps its schedule after the behavior is removed, since the check cannot see the behavior until the command runs.