Skip to content

Latest commit

 

History

History
456 lines (366 loc) · 158 KB

DESIGN.md

File metadata and controls

456 lines (366 loc) · 158 KB

The PHP port

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.

Rules

  • 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.2 and 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-json and ext-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 no mbstring or intl is needed: UTF-16 lengths, cuts, and scrubbing are done on the bytes. The stores need pdo_sqlite, pdo_mysql, or pdo_pgsql, named in suggest and checked with a clear LogicException when missing. The channels and triage use ext-curl when it is loaded and PHP's own http:// 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, so Evaluate ports line for line and stored rows are identical. PHP's int is 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 and Js::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's startedAt is 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: JobState keeps 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 a sending entry): 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 {}. A sending entry 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 is JSON.stringify byte for byte: numbers as JavaScript prints them (2 not 2.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_encode differs from JSON.stringify on each of these points, and is used nowhere a byte counts.
  • Alert titles and messages are the SDK's text, character for character. JobDefinition keeps its fields as given, in order (defaults, then options as given, then name, and a stored expect last), 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 /u also turns on Unicode \s, \b, and case folding, so JavaScript's whitespace class is spelled out, \b is spelled with ASCII word characters, and case-insensitive words are spelled [Ss][Ee]... (ASCII-only folding, as JavaScript's /i has 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 (Exception or Error): 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 no catch sees) is recorded by the client's shutdown hook as a failed run, Interrupted: the process exited during the run or Interrupted: 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: message and up to five frames, innermost first, each at 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 reads NODE_ENV; the Python port's _env.py has the same shape): the first of CRONWATCH_ENV, APP_ENV (Laravel, Symfony, Craft), and WP_ENVIRONMENT_TYPE (WordPress) whose value, trimmed, is not empty (a value of only spaces counts as unset), lowercased, from getenv(), $_ENV, or $_SERVER, since frameworks that read a .env file put its values in one of those; failing those, what a framework integration names with Env::setFallback() (the WordPress plugin gives wp_get_environment_type(), which also reads the constant; Laravel's gives the app's environment, Symfony's the kernel's). development, dev, and local are development (Symfony's dev and Laravel's local included), production and prod production, and anything else (test, testing, staging) neither, so a test run or CI needs CRON_SECRET or a token, as production does (Env::environmentOf(), which every port reads the same way, replayed from conformance/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.

Layout

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

The PHP API

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:

  • null for cronSecret:, the dashboard's token: and a handler's secret: turns it off, as in every other port; until 0.11 it was the default and read the environment, and false turned it off. false still does, deprecated. The default is now Cronwatch\FromEnv::Read (on a handler, the client's secret).
  • JobHandle::wrap() is monitor(), 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).

Runs that span calls

$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.

One thing at a time

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.

Delivery

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.

Channels

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(), in NativeHttp and the WordPress plugin's WpHttp alike), 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 gets limit_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's ERROR_READ_MAX), and Shared::errorText() holds an Http that 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 onError with 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

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

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.

Keeping in step

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.

Storage

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 LONGTEXT holding the SDK's JSON byte for byte, never MySQL's JSON type, which reorders keys and rewrites numbers and spacing (MariaDB's JSON is text, but one schema serves both). The state's version is read as Evaluate::stateVersion() reads it (a whole number from 0 to 2^53 - 1, else 0, so a foreign row's 1.5 or "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 ... is JSON_EXTRACT(state, '$.version'). The type is tested before any conversion, since a string's conversion warns and strict mode makes that an error in an UPDATE; plus 0, MySQL's JSON_EXTRACT (which answers JSON) and MariaDB's (which answers text) are both a number. A run's duration is Evaluate::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 TEXT column cannot be a primary key, so names and ids are VARCHAR(255), with the utf8mb4_bin collation so they compare as bytes, as SQLite and Postgres compare them ("b" and "B" are two jobs; names sort in byte order).
  • seq breaks ties between runs started in the same millisecond, as SQLite's rowid and Postgres's seq do. trigger is a reserved word, so the statements quote it.
  • There is no partial index, so runs_running indexes every status. CREATE INDEX IF NOT EXISTS is MariaDB's only, so the indexes are declared in the tables.
  • There is no ON CONFLICT: upserts are ON DUPLICATE KEY UPDATE. compareAndSetState from version 0 is an UPDATE of a row at version 0 and, failing that, an INSERT that a row already there refuses (a duplicate key); from any other version it is one conditional UPDATE. Neither leans on how the connection counts affected rows, so an app's connection with FOUND_ROWS on gives the same answers.
  • MySQL refuses a subquery on the table a DELETE deletes from, so prune joins in the newest start per job as a grouped derived table.
  • MySQL counts only the rows an UPDATE changed, so updateRunIf over 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.

Web

$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 as public/cronwatch.php: Request::fromGlobals() reads $_SERVER (the request target as sent in REQUEST_URI, so a %2F in a job name stays one; the headers from HTTP_*, CONTENT_TYPE, and the Authorization Apache hides in REDIRECT_HTTP_AUTHORIZATION or getallheaders(); the scheme from HTTPS; the body from php://input, or $_POST and $_FILES for a multipart form, which PHP reads itself), and Response::send() writes the status, the headers, and a Content-Length with header(), then the body unless the method is HEAD (so a HEAD's Content-Length is its GET's). PHP's own X-Powered-By is removed and its default Content-Type is not added to a redirect, so the headers on the wire are the SDK's; WebServeTest checks them under php -S.
  • PSR-7 and PSR-15, for Slim, Mezzio, and anything that speaks them: Request::fromPsr() and Response::toPsr($responseFactory, $streamFactory) are duck typed, so nothing requires psr/http-message; PsrHandler (a RequestHandlerInterface) and PsrMiddleware (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 (%zz becomes %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.

Handler

$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 with header() and echo (a Web\Response by its send(), an HttpFoundation response by its own, a PSR-7 one copied out). HandlerTest runs it under php -S.
  • __invoke($request) answers in the request's kind: an HttpFoundation response for a Symfony request (Laravel's Illuminate\Http\Response for a Laravel request, so Laravel middleware that call its helpers work), a Web\Response otherwise. 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 is return $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 in routes/web.php goes through Laravel's CSRF middleware, which refuses a cron's POST; put it in routes/api.php or exclude it (->withoutMiddleware(PreventRequestForgery::class), ValidateCsrfToken before 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's PsrHandler is) 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.

Frameworks

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 the Cronwatch client with its jobs declared (or a callable that returns it), the same file the app's own scripts can require: --bootstrap <file>, else CRONWATCH_BOOTSTRAP, else cronwatch.php, else config/cronwatch.php in 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's cronwatch_check print, cronwatch: checked 3 jobs, sent 1 alert (--quiet for 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\Cronwatch is a singleton made from config/cronwatch.php (merged from the package's copy, published with vendor: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. onError writes to the app's log with the exception. The app's environment ($app->environment()) is the fallback of Env, so local is development and testing is not. An app that wants a client of its own binds Cronwatch\Cronwatch again in its own provider's register(), which runs after the package's.
    • The store. database (the default) keeps the three tables in the app's database: DatabaseStore reads one of config/database.php's connections (the default, or CRONWATCH_DB_CONNECTION), MySQL and MariaDB, Postgres, or SQLite, as Laravel has resolved it (a DB_URL taken 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, and search_path. So its writes never join a transaction the app has open (a run recorded inside DB::transaction() survives the rollback, which ProviderTest checks 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, default cronwatch_); the connection's own table prefix does not apply to them, so every port reads the same tables. sqlite keeps a file of its own (storage/cronwatch/cronwatch.db by default), and memory is for tests. SQL Server is refused with a message.
    • The migration. migrations/2026_09_28_000000_create_cronwatch_tables.php is loaded with loadMigrationsFrom (for the database store; CRONWATCH_MIGRATIONS=false to leave it out) and publishable with vendor:publish --tag=cronwatch-migrations. It calls the store's own init(), so the tables' CREATE text is the SDK's for the database, byte for byte, through the store's connection, with IF NOT EXISTS (running it where the tables are already there changes nothing); down() drops them. Each process still runs CREATE TABLE IF NOT EXISTS on first use, as every store does; for a database user who may not create tables, CRONWATCH_CREATE_TABLES=false (create_tables) wraps the store in Store\Migrated, whose init() does nothing. down() drops the tables the store names ($store->prefix), so it reads no setting itself.
    • Settings keys. table_prefix, create_tables, check.schedule and check.frequency are spelled as the Symfony bundle spells them, top-level for the tables since the bundle's store is a URL, as is dashboard.token; site/docs/laravel.md has every key beside the bundle's. Before 0.11 they were store.prefix, store.create_tables, schedule.check and schedule.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 an E_USER_DEPRECATED notice once per key per process (@trigger_error, which Laravel logs to its deprecations channel), through 1.x; 2.0 drops them. ProviderTest checks 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): ScheduledTaskStarting starts a run (trigger "laravel-scheduler", "schedule" before 0.11, which runToFinish() still accepts so a background task running across the upgrade is finished; through startExecution(), the first half of run(), so a callback's Cronwatch::current() is its context and a process that dies mid-task records the run as interrupted); ScheduledTaskFinished ends it, failed with Exited with code N for a command that exited non-zero, Returned false for a callback that returned false, else ok, a callback's return value treated as run()'s (a string is the output, an HTTP response of 400 or more fails it); ScheduledTaskFailed ends a run still open with its exception (a callback that threw; Laravel dispatches it after ScheduledTaskFinished for 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, unless cronwatch.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 after ScheduledTaskStarting, so the watcher checks the lock ($event->mutex->exists()) before it starts a run, and records nothing (Laravel 13 also sets skippedBecauseOverlapping: when the lock was taken between the two looks, the run already started is taken back with discardExecution(), 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 in schedule:run and is finished by schedule:finish, which Laravel runs when the command ends, in a process of its own (ScheduledBackgroundTaskFinished): the oldest running schedule run of the task's job, else the newest a check has marked timeout (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. ScheduledTasks names 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 by Bridge\JobName (emails:send --force is emails: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; a Schedule::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 to onError; 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 a WeakMap (PHP 8.2 deprecates adding a property to it). Jobs are tagged laravel-scheduler and laravel-scheduler:<app>, the app's name (CRONWATCH_APP_ID, else app.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:run and schedule:finish start (CommandStarting), at the first scheduler event, and by the check, which also declares every job tagged laravel-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_ID when their APP_NAMEs are the same (Laravel's default is Laravel). 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 tagged laravel-scheduler alone (by an earlier version) is taken for this app's, and given its tag, only while no other app's laravel-scheduler:<app> tag is in the store; with another app there it could be either's and is left alone. ScheduleTest checks two apps on one store, and BridgeTest the older tags.
    • The check. php artisan cronwatch:check declares 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=false to leave it out).
    • Queued jobs. A job class marked #[Cronwatch\Watch(...)] (its options as named arguments, enabled: false to opt out) or implementing Cronwatch\Laravel\ShouldBeWatched (options from a static cronwatch() 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): JobProcessing starts the run (Cronwatch::current() is its context), JobProcessed ends it ok, or failed when the job marked itself failed; JobExceptionOccurred and JobFailed end it failed with the exception, whichever comes first; JobTimedOut ends 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, and failuresBeforeAlert rides through retries. A job released back onto the queue without an exception (a RateLimited or WithoutOverlapping middleware, or a deliberate $this->release()) did not fail and did not do its work, so its attempt is not a run: JobProcessed sees it released, and the run started at JobProcessing is taken back (Cronwatch::discardExecution(), which deletes the row while it is still running and of this job, through the store's deleteRunIf, 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 are running, ok, failed, and timeout, and a fifth would be a change to every port and the dashboard; an ok row would make the job look healthy between failures (health reads the last run), and a failed one 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 to onError as discarding <job>, as is a store of the app's own without deleteRunIf. A release after an exception is that exception's failed attempt, as Celery's self.retry() is. A job a middleware skips without releasing it (WithoutOverlapping or RateLimited with ->dontRelease(), Skip) is deleted by Laravel with its handler never run, and JobProcessed cannot 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 by CallQueuedHandler that uses InteractsWithQueue (so the command carries its queue job) is judged this way; any other is ended as before. QueueTest has a job released by itself and by its middleware, failures around a release with failuresBeforeAlert: 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. A Schedule::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's Dashboard (Web\HttpFoundation turns Laravel's request into the dashboard's and its answer into Laravel's response). The route group's middleware is web and Http\Authorize, which asks the viewCronwatch gate with the signed-in user (by default, as Horizon's and Telescope's gates do, it lets anyone in the local environment 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/mcp and a platform cron, which have no session. Laravel's CSRF middleware (PreventRequestForgery in 13, ValidateCsrfToken before) 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:run recording callbacks and shell commands, output files, overlapping, a background task finished by schedule: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:work on 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), and ProviderTest (the store through its own connection surviving a rollback, on SQLite, MySQL, MariaDB, and Postgres; the migration's CREATE text; create_tables; the channels; the mailer; the environment).
  • Symfony. A bundle, Cronwatch\Symfony\CronwatchBundle, registered in config/bundles.php and configured in config/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\Cronwatch service (alias cronwatch) from the bundle's configuration: store, a URL written as Doctrine writes one (Symfony\Stores: mysql://, postgresql://, sqlite:///<path>, memory, Doctrine's serverVersion and a Postgres charset left out), by default the app's DATABASE_URL, else var/cronwatch.db, always through a connection of CronWatch's own; store_service for a store service of the app's; table_prefix; create_tables (false wraps the store in Store\Migrated); the channels (mailer through symfony/mailer, slack, discord, webhook, log, and AlertChannel services; with none, alerts go to the logger, Monolog's cronwatch channel when the app has Monolog); triage; cron_secret; retention, defaults, and delivery. The kernel's environment is the fallback of Env.
    • Watching the Scheduler with no code changes. SchedulerSubscriber listens to the Scheduler's PreRunEvent, PostRunEvent, and FailureEvent, which the worker consuming a schedule (messenger:consume scheduler_default) dispatches around each message: PreRunEvent starts a run (trigger "symfony-scheduler", "scheduler" before 0.11; the handler's Cronwatch::current() is its context), after every other listener so a run another cancels is not started; PostRunEvent ends it ok with the handler's result (a string is the output, an HTTP response of 400 or more fails it; on 6.4, whose PostRunEvent has no getResult(), the result is kept from Messenger's WorkerMessageHandledEvent just before); FailureEvent ends it failed with what the handler threw, taken out of Messenger's HandlerFailedException.
    • Jobs. ScheduledMessages reads every schedule (the scheduler.schedule_provider services, 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; a RedispatchMessage after the message it carries. A schedule other than default puts 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's scheduler.jobs does the same by job name (scheduler.exclude leaves names out). The schedule is the trigger's: a CronExpressionTrigger's expression (a hashed # one as Symfony resolved it) and zone, read from the trigger; a PeriodicalTrigger's interval as every <seconds>s; a JitterTrigger's inner trigger. A trigger that does not fire at fixed times (ExcludeTimeTrigger, CallbackTrigger, a calendar interval such as 1 month) leaves the job without a schedule, reported once, and so does a PeriodicalTrigger whose from is still to come or whose until has passed (by the client's clock, when the job is declared; a worker's scheduler sets an unset from to the time it starts, which is never ahead of it). Collisions, tags (symfony-scheduler and symfony-scheduler:<app>, the app named by the bundle's app_id, else app- 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 of APP_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 an app_id each), the check never being a job, and jobs of messages taken out of a schedule are as for Laravel.
    • Messenger. MessengerSubscriber records 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, and CheckMessage, whose handler declares every schedule's jobs and checks. The bundle adds CheckMessage every five minutes (check.frequency, a period or a cron expression) to the app's default schedule (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. So CheckSchedulePass (before the Scheduler's own pass) makes CheckSchedule decorate the app's provider for that schedule (its #[AsSchedule] class, or the one #[AsCronTask] tasks make) and add the check to its Schedule once, unless the schedule already sends a CheckMessage; with no provider for that name, CheckSchedule is the schedule's provider, and the Scheduler makes its transport. check.schedule: cronwatch gives the check a schedule of its own (consume scheduler_cronwatch too), and check.schedule: false leaves 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 --status checks 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:scheduler lists 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 to DashboardController. Behind the app's security, with an access_control rule for the prefix, a signed-in user granted dashboard.role (ROLE_ADMIN by 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, else CRONWATCH_TOKEN), or CRON_SECRET for /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), and ConfigTest (store URLs, channels, create_tables, the logger default, the environment).
  • 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 namespace Cronwatch\WordPress); php wordpress/build.php makes the plugin directory's zip, cronwatch/ with the plugin, the library's src/ in lib/src, and its MIT text in lib/LICENSE, nothing else, the dashboard's stylesheet as css/dashboard.css (written from Html::CSS as the zip is built, not checked in, so it cannot drift from the library's pages; PluginTest checks the two are the same bytes), byte for byte the same on every build, and refuses to build unless Cronwatch::VERSION, the header's Version, and the readme's Stable tag agree and the readme's changelog has a = X.Y.Z = section for that version (release.mjs bumps all three and adds the section). The zip leaves out the library's files the plugin never runs (LEFT_OUT in build.php): NativeHttp (every request goes through wp_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, false in 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/ but ChannelSettings, Watch, Store\Migrated). A developer who wants triage installs the Composer package, whose autoloader finds the classes the zip leaves out, and adds it through cronwatch_client_args. PluginTest checks that no zipped file calls curl_*, 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 for Transport's NativeHttp, Html's StandaloneHead, and Dashboard's DevelopmentToken, each reached only when nothing else was given, and the plugin always gives something; Dashboard also asks class_exists first).
    • Changelog. The readme's changelog is what the directory shows under a release, so a released section is never rewritten: release.mjs adds 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_event and pre_unschedule_event (returning $pre untouched), which wp-cron.php and WP-CLI's wp cron event run pass through just before do_action_ref_array(), to note the event about to run. On first sight of a hook it adds a callback at PHP_INT_MIN that starts a run (trigger: "wp-cron", Jobs::TAG, so the trigger and the tag cannot drift) and one at PHP_INT_MAX that finishes it, keeping what the callbacks echo as the output after any lines from the cronwatch_log action (the documented form, harmless with the plugin inactive; the cronwatch_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 (a do_action() by hand) is not a run.
    • Jobs. A recurring event is wp:<hook> with schedule every <interval>s from its recurrence (hourly 3600, twicedaily 43200, daily 86400, weekly 604800, custom ones from cron_schedules); one with arguments is wp:<hook>:<key>, the key the first 8 hex characters of WordPress's own md5(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_check and 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 tagged wp-cron, described by hook, arguments, and recurrence, and adjustable by filters (cronwatch_watch_event, cronwatch_job_options). Options from cronwatch_job_options that the library refuses (a timeout of 2 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 its file:line, or Interrupted: 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 through wp_die(), which exits and so stops every later shutdown function; the plugin records its open runs from inside that path (the wp_php_error_message and wp_die_handler filters) 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. WpdbStore keeps the MySQL dialect's three tables through $wpdb, named with the site's prefix and cronwatch_ (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 (%s for a string, %d for an integer; a null is NULL, and a number that is not a safe integer its digits, since %f would round it), called where the query runs; the table names stay written into the library's text, which MysqlStore shares (the constructor checks the prefix against [A-Za-z0-9_]), and the plugin's own DROP TABLE names them with %i; the stored bytes are MysqlStore's, which a test checks column by column on one database. The tables are made on activation with CREATE TABLE IF NOT EXISTS, not dbDelta(), which would keep "fixing" the descending index and the expression default; cronwatch_db_version marks 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 its DEFAULT ('{}'), 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 recommends DISABLE_WP_CRON and a system crontab calling wp cron event run --due-now (or wp-cron.php) and wp 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), behind manage_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, and admin.php?page=cronwatch&cw=<path> answers with the dashboard's page at <path> from the load-<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's Authorization header taken out, as Drupal and Craft do, so a bearer cannot make a GET of /api/check run 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 to admin.php?page=cronwatch&cw=<path> (with the nonce on each form's action); its pages' head is the plugin's (the dashboard's head: option): the stylesheet, css/dashboard.css, registered with wp_register_style, enqueued, and printed with wp_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 less script-src, manifest-src, and worker-src, with style-src 'self' 'unsafe-inline' (and the plugins' origin when WP_PLUGIN_URL puts them on another; the timeline places its marks with style attributes), and X-Frame-Options and frame-ancestors allowing 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 and wp cronwatch check to run it now (the dashboard's empty: 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 /api paths. Its permission_callback checks Authorization: Bearer <token> by the token's SHA-256 against the saved digest with hash_equals (the settings keep only sha256:<hex>, never the token, and a token an earlier release saved as it was is changed to its digest the first time Plugin::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's cronwatch_client_args gives 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 a WP_Error with status 401 whose body, written by the plugin, is the dashboard's own {"ok":false,"error":"Unauthorized"} with its headers, which @cronwatch/mcp reads, 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%252e is 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 of A-Z a-z 0-9 . _ ~ + / = - and its digest is taken of it as typed; anything else is refused, since sanitize_text_field would 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/mcp is rest_url('cronwatch/v1') (https://example.com/wp-json/cronwatch/v1, or https://example.com/?rest_route=/cronwatch/v1 without pretty permalinks; the MCP server appends /api/... to it either way). The body is the library's bytes, written in rest_pre_serve_request rather 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.php already covers every site.
    • Settings. CronWatch, Settings, behind manage_options and a nonce on every form: email through wp_mail (a WordPress channel composing the library's email), a Slack URL and a webhook URL and secret (the library's channels, over wp_remote_post with 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 prefixed cronwatch_; uninstall.php drops the tables and removes every option and event, on every site of a network. Developers get cronwatch_alerts and cronwatch_client_args; a channel added there without an http: goes through wp_remote_post too (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 uses array_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's DROP TABLE and WpdbStore::drop() name the tables with %i placeholders, the prefix still checked against [A-Za-z0-9_]). The library is not WordPress code and runs without it, so it cannot call esc_html or wp_parse_url: where Plugin Check reads a library line as WordPress code (a parse_url, the default channel's error_log) the line carries a phpcs:ignore (or a phpcs:disable and phpcs:enable around a few lines) naming the rule and why, and build.php adds one line after each library file's declare() 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. Sql names no PDO (its bind() is PdoStore's), and Js calls \array_is_list fully 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 in wordpress/assets/, which build.php leaves out: banner-772x250.png and banner-1544x500.png (the header banner, standard and high DPI), icon-128x128.png and icon-256x256.png (or one icon.svg), and screenshot-1.png to screenshot-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.mjs renders the icons and banners from icon.svg and banner.html (its timeline strip redrawn from the site's captured dashboard); the screenshots are captures of a real WordPress running the built plugin, which screenshots/run.sh retakes: it starts WordPress and MySQL in Docker, installs the zip build.php makes 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 and icon.svg go to SVN.
    • Tests. tests/WordPress/WordPressTest.php installs WordPress with WP-CLI into a temporary directory against a real MySQL or MariaDB (CRONWATCH_TEST_WORDPRESS, a mysql:// 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 through wp commands, wp eval-file, and wp-cron.php, wp-admin (signed in with cookies WordPress made for the test users), and the REST route under php -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 turns CREATE TABLE into 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.php checks the naming, the schema per server, and the zip's contents without WordPress.
  • Drupal. A module (drupal/, machine name cronwatch, namespace Drupal\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 in cronwatch.module, which both majors call (11 deprecates only hook_module_implements_alter and hook_requirements in that form, and neither is used).
    • Watching cron with no code changes. Drupal runs every module's hook_cron from the cron service (Automated Cron after a response, drush cron, /cron/<key> from a system cron), in Cron::invokeCronHandlers(), which calls each through ModuleHandler::invokeAllWith('cron', ...), catches an \Exception so one module cannot stop the others, and lets an \Error end the run. CronwatchServiceProvider::alter() makes the service WatchedCron, a subclass of core's Cron, 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 \Exception caught) 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, which invokeAllWith() 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 to invokeAllWith() 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 own alter() has run (both run in module order, so ultimate_cron comes after cronwatch) would undo it, so CronServicePass, a compiler pass the provider's register() adds, runs after every alter() (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 parameter cronwatch.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 its hook_cron, watched as drupal:scheduler like any other.
    • Jobs. drupal:cron is the whole run (trigger "drupal-cron", "cron" before 0.11; its output lists the modules that ran and those that failed), and drupal:<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 \Error fails 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 only drupal:cron has a schedule: the one under the settings (what the system crontab does, a cron expression or every 1h), else Automated Cron's interval as every <interval>s (Automated Cron runs cron after the first response once system.cron_last plus the interval has passed, which is what every means), 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 tagged drupal-cron, and hook_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 stops prepare() declaring the rest, the check, or its own runs being recorded (drupal:cron without its schedule is still recorded). Jobs of modules uninstalled have no schedule and are never missed; a drupal:cron schedule taken away is dropped at the next declaration.
    • Ultimate Cron. Read against Ultimate Cron 8.x-2.0-beta1 (drupal/ultimate_cron 2.0.0-beta1, the current release, for Drupal 9.3 to 11). It replaces the cron service with UltimateCron (a subclass of core's Cron), whose run() loads its enabled jobs (ultimate_cron_job config entities: one per module's hook_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, invokes hook_cron_pre_run, calls invokeCallback(), catches an \Exception or \Error into the log entry at error level, then closes the entry, invokes hook_cron_post_run, and lets the lock go. Its hooks were weighed and passed over: hook_cron_post_run comes after the exception has been caught, flattened into the log entry's markup and the job's run() has returned FALSE to 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 entity UltimateCron\WatchedCronJob, a subclass of CronJob (only while the class is still Ultimate Cron's own), whose invokeCallback(), 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 (so Cronwatch::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. CronServicePass makes the service UltimateCron\WatchedUltimateCron, a subclass whose run() is Ultimate Cron's with the whole run recorded as drupal: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 through setClass(), which keeps the original as a decorated class, so CronJob::load() still finds the type.
      • Names. A module's hook_cron job (id <module>_cron, callback <module>#cron, or <module>_cron before Ultimate Cron's update to that form) is drupal:<module>, as under core, so its history goes on when Ultimate Cron is installed or removed; any other job is drupal: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:cron keeps "drupal-cron". Tags are drupal-cron as under core, and hook_cronwatch_job_options_alter() sees kind ultimate_cron with job and module.
      • 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\Rules asks 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, */n where those say it, else a list), so */15+@ for a job whose skew is 7 is 7,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. The catch_up setting (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 by drupal: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 of drupal: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 trigger ultimate-cron-manual there. 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, the catch_up setting, and its lock timeouts are not read.
    • Queues. Queue workers run in cron (those whose definition has a cron time) or from drush queue:run. The owner picks workers under the settings, or a worker class carries #[Cronwatch\Watch] (its options, name included; enabled: false leaves it out). hook_queue_info_alter() puts WatchedQueueWorker in a watched worker's definition, keeping its class; that makes the real worker as the plugin system would (through its create() when it has one) and records each processItem() as a run of drupal:queue:<worker> (trigger "drupal-queue", "queue" before 0.11), in cron, Drush, or anywhere else. What the worker throws, a failure or a RequeueException, DelayedRequeueException, or SuspendQueueException asking 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 tagged drupal-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 from drush 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, and hook_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 missed drupal:cron is how a site learns it needs one.
    • The store. Storage reads the connection settings.php describes ($databases['default']['default'], or the key $settings['cronwatch_database'] names): MySQL and MariaDB (the mysql driver, and 11's mysqli), 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, and cronwatch_ (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 own init(), 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_key say, added to an existing site by cronwatch_update_10001), the grace (default 10m), the schedule, check_on_cron, and the watched queues. Email goes through Drupal's mail system: MailChannel asks the mail manager, and hook_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, a ConfigFormBase with 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, the cronwatch channel, which is also onError); hook_cronwatch_alerts_alter() adds channels. Configuration is exported with the site's, so the README shows credentials set in settings.php.
    • Permissions and the dashboard. administer cronwatch (the settings, and changes from the dashboard) and view 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 needs administer cronwatch and 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\EmbeddedDashboard gives 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 marked no_cache. The JSON API for @cronwatch/mcp is /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'], else CRONWATCH_TOKEN), then answered by the dashboard with that token. A POST to /api/check declares 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 whose Host the visitor chose, so there the alert goes without a link rather than with one to that host.
    • Tests. tests/Drupal/DrupalTest.php makes a Drupal project with Composer (drupal/core-recommended at CRONWATCH_TEST_DRUPAL, Drush, and the library and the module from this checkout as symlinked path repositories), installs a site with drush site:install minimal on SQLite, MySQL, or Postgres (CRONWATCH_TEST_DRUPAL_DB), enables the module, Automated Cron, and fixtures/cwt_fixtures (a hook_cron that logs, throws, or throws an \Error as 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 \Error failing the cron run), the schedule from the settings, from Automated Cron, and none, drush cronwatch:check reporting 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: KernelTestBase and BrowserTestBase need 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.
  • Craft CMS. A plugin (craft/, handle cronwatch, 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 by craft queue/run or queue/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 (from config/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_EXEC starts the attempt's run (trigger "craft-queue", "queue" before 0.11, id craft-queue:<job id>:<attempt>:<random>; Cronwatch::current() is its context), EVENT_AFTER_EXEC ends it ok, and EVENT_AFTER_ERROR ends it failed with the error, whether the queue retries the job (a RetryableJobInterface job's canRetry(), 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-level EVENT_BEFORE_ACTION and EVENT_AFTER_ACTION: a route the settings' commands list ('resave/entries' => ['schedule' => '0 3 * * *'], or true for runs without a schedule) is a run of craft:<route> (slashes as colons: craft:resave:entries) unless the options name it, trigger "craft-command" ("command" before 0.11), ended ok, or failed with Exited with code N for a non-zero exit. An exception ends a command before EVENT_AFTER_ACTION, so the console error handler's EVENT_BEFORE_HANDLE_EXCEPTION ends every open command run failed with it. A watched command that runs another ($this->run()) and catches what that one threw reaches its own EVENT_AFTER_ACTION with the inner run still open and the error handler never called: each run is kept with its action, and EVENT_AFTER_ACTION finds 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 the WatchCommand behavior (actions limits it to some actions); the settings win for a route both name. A command's output goes to the terminal through stdout(), which PHP cannot read back, so a run's output is what it logged through Cronwatch::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 (tagged craft-config as 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. Storage reads Craft's database connection (its DSN, credentials, PDO attributes, and Postgres's schema as a search_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's utf8mb4 applies. The tables are Craft's table prefix, lowercased, and cronwatch_. The plugin's install migration (migrations/Install.php) makes them with the store's init(), and its safeDown(), 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\ChannelSettings in camel case, resendApiKey say), grace, apiToken, and, from config/cronwatch.php only, commands and queueJobs. Each string may name an environment variable ($SLACK_WEBHOOK_URL, read with App::parseEnv()), so credentials stay out of project config, and config/cronwatch.php wins 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) sends Bridge\TestAlert to every channel of the saved settings and shows what each answered. With no channel, alerts go to Craft's log (category cronwatch, also onError); Plugin::EVENT_ALERTS (an AlertsEvent) adds channels. Craft keeps plugin settings in project config, so a production site that disallows admin changes sets them in config/cronwatch.php, as Craft recommends for any plugin.
    • Permissions and the dashboard. A Control Panel section (hasCpSection), CronWatch, at admin/cronwatch: a page with the dashboard in a frame, and admin/cronwatch/view?cw=<path> the dashboard's page, both behind accessPlugin-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's cronwatch-manage permission, and Craft's CSRF validation, on for every Control Panel POST, needs its token, which EmbeddedDashboard puts 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 until apiToken or CRONWATCH_TOKEN is set, then answered by the dashboard with that token. A POST to /api/check declares 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()), from baseCpUrl when it is set, else from @web. When no config sets @web Craft takes it from each request, and in a web request (a queue job the Control Panel's queue runner runs) that is the Host a visitor sent, so there the host is the primary site's configured URL, and a site whose URL is @web itself sends alerts without a link.
    • Tests. tests/Craft/CraftTest.php makes a Craft project with Composer (craftcms/cms at CRONWATCH_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.php loading fixtures/cwt, and a config/cronwatch.php), runs craft install on 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.
  • 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).

Phases

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.

  1. 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.
  2. 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.
  3. 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), matching packages/ruby/test/web/golden.json byte 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.
  4. 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:check scheduled by the provider, the store in the app's database through its own connection and its migration, the dashboard behind the viewCronwatch gate) and Symfony (a bundle: Scheduler messages watched through its events, Messenger messages that opt in, cronwatch:check and 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.
  5. 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.

Releasing

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):

  1. 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.
  2. 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 secret PHP_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.
  3. Set this repository's variables PHP_SPLIT_REPOSITORY to cronwatchdev/cronwatch-php and PHP_SPLIT_ENABLED to true.
  4. Run the workflow by hand for the current release tag (Actions, PHP split, Run workflow) so the split repository has a main and a tag.
  5. 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.

The Drupal module and the Craft plugin

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 as drupal/cronwatch from packages.drupal.org/8, reading the module's composer.json for its requirements, so cronwatch/cronwatch comes from Packagist and must be published first. The workflow pushes git subtree split --prefix=packages/php/drupal to the branch X.Y.x and the tag X.Y.Z (drupal.org's semantic version names, without the v), 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.json at the root, so the workflow pushes git subtree split --prefix=packages/php/craft to its own GitHub repository's main with the tag vX.Y.Z, and Packagist publishes cronwatch/craft from it.

The owner's steps, once, before the first release of each (nothing here creates a repository or an account):

  1. 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 secret DRUPAL_SPLIT_SSH_KEY, set the variables DRUPAL_SPLIT_REPOSITORY to git@git.drupal.org:project/cronwatch.git and DRUPAL_SPLIT_ENABLED to true, and run the workflow by hand for the current tag. On drupal.org, make a release from the pushed X.Y.Z tag (Releases, Add new release), mark the branch X.Y.x supported 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.
  2. 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 secret CRAFT_SPLIT_DEPLOY_KEY, set CRAFT_SPLIT_REPOSITORY and CRAFT_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.

Where it cannot match the SDK

  • Without a timezone, a cron is read in PHP's default zone (date_default_timezone_get(), from date.timezone, usually UTC), which is what "local time" means in PHP, where the SDK reads the process's TZ. Zone names are matched without regard to case against PHP's zone database, as Intl matches them; PHP's abbreviations and offsets (EST, +02:00), which new 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 fetch has), 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's x-stainless-* telemetry headers. The key comes from apiKey or ANTHROPIC_API_KEY; the client's ant auth login profiles 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's options drop a schedule or timezone given 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\ResponseStatus reads rather than one fetch Response, 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 with HTTP 503 Service Unavailable where a fetch Response made without a statusText gives HTTP 503). Its answer through HttpFoundation (a Laravel route, a Symfony controller) carries Cache-Control: no-store, private: HttpFoundation adds private to any Cache-Control without public or private. The dashboard mounted in Laravel or Symfony is served the same way, so its headers are HttpFoundation's rendering of the SDK's (a Set-Cookie written by Symfony's cookie class, private added 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.
  • SqliteStore without a path uses data/cronwatch.db in the app's root (Composer's root package), where the SDK's ./data/cronwatch.db is the working directory's: under PHP-FPM or mod_php the working directory is the script's, often public/, so the dashboard and the cron check would keep separate files and one could be served.
  • A multipart form is read from $_POST and $_FILES under serve(), since PHP reads the body itself; PHP turns . and spaces in field names into _ and reads a[b] as an array there. The dashboard's own forms are URL encoded and read from the body as URLSearchParams reads 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 in undelivered that 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. A sending entry is kept as stored until it is released; then its alert joins undelivered the same way, and an entry whose alert is 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.
  • expect patterns are PCRE, not JavaScript regular expressions, and are marked by Pattern; 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 Throwable passed to finish() on its own is a failure, where the SDK treats any object without an error key 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. DeletesRunIf has no SDK counterpart: it takes back a Laravel queued job's released attempt (see Frameworks).
  • In Drupal, a module's hook_cron has no schedule of its own (Drupal runs every hook on every cron run), so only drupal:cron can be missed; a module's job reports failures, slow runs, and stuck runs. Automated Cron's interval is read as every <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-By where 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 through Cronwatch::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.