cron-monitor/php-sdk

PHP SDK for cronheart.com (cron-monitor) with first-class Symfony Scheduler and Laravel Scheduler support.
1,473
Install
composer require cron-monitor/php-sdk
Latest Version:v1.5.5
PHP:>=8.2
License:MIT
Last Updated:Oct 2, 2026
Links: GitHub  ·  Packagist
Maintainer: alexander-po

cron-monitor PHP SDK

Composer SDK for cronheart.com — heartbeat monitoring for scheduled jobs. Get pinged when a cron / systemd timer / scheduler entry stops checking in on time. First-class support for Symfony Scheduler and the Laravel scheduler.

CI Latest Version Monthly Downloads PHP Version License: MIT

Why

Uptime monitors don't catch the silent failure mode: a backup that stopped running a month ago, an invoice job that didn't fire on the 1st, an ETL pipeline whose systemd timer was renamed. cronheart's per-job dead-man switch does. This SDK takes the boilerplate out of wiring it up.

What's in the box

  • Drop-in Symfony bundle — auto-registered; bin/console cron-monitor:sync inventories Scheduler RecurringMessage providers and console commands.
  • Drop-in Laravel package — auto-discovered service provider; Schedule::command(...)->monitor() macro and MonitorQueueJob middleware for ShouldQueue jobs.
  • #[Monitor(uuid: ...)] attribute — the UUID lives on the command class instead of being duplicated in YAML / config. Works on both Symfony Console and Laravel Artisan commands.
  • Zero extra dependencies — bundled cURL PSR-18 transport and nyholm/psr7 factories. Bring Guzzle or symfony/http-client if you want connection pooling; otherwise composer require is enough.
  • Never breaks the host job — every network / HTTP error becomes a PingResult::failed(...) return value. A broken cron-monitor backend cannot punish your scheduler.

Install

composer require cron-monitor/php-sdk

PHP ≥ 8.2 with ext-curl (the bundled cURL transport uses it; almost every PHP install has it on).

composer require is the only step. The SDK ships with its own minimal cURL PSR-18 transport plus the PSR-17 factories from the bundled nyholm/psr7 — no Guzzle / symfony/http-client required.

  • Symfony bundle path — drop-in. The bundle prefers symfony/http-client's Psr18Client if present and falls back to the bundled cURL transport otherwise. Your own PSR-17 / PSR-18 bindings always win.
  • Laravel path — auto-discovered. The service provider uses bindings from the container when present and falls back to the bundled cURL transport + nyholm/psr7 factories.
  • Framework-agnostic path (plain PHP / Slim / etc.) — use CronMonitorClient::create() (see Quick start). Want to plug in Guzzle or symfony/http-client? Construct CronMonitorClient directly and pass your PSR-18 client + PSR-17 factory.

Quick start (framework-agnostic)

use CronMonitor\Client\CronMonitorClient;

CronMonitorClient::create()->success('xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx');

Wrap a long-running job:

use CronMonitor\Client\CronMonitorClient;

$client = CronMonitorClient::create();
$uuid   = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx';

$client->start($uuid);
try {
    runMyImportantJob();
    $client->success($uuid);
} catch (\Throwable $e) {
    $client->fail($uuid, $e->getMessage());
    throw $e;
}

The client never throws on network or HTTP errors — a broken cron-monitor backend will not break your job.

For a custom endpoint (staging / preview), tighter timeout, or API key:

use CronMonitor\Client\Configuration;
use CronMonitor\Client\CronMonitorClient;

$client = CronMonitorClient::create(
    new Configuration(
        endpoint:       'https://staging.cronheart.com',
        timeoutSeconds: 3.0,
        retries:        2,
        apiKey:         getenv('CRON_MONITOR_API_KEY') ?: null,
    ),
);

Symfony Scheduler integration

Register the bundle (Flex usually does this for you):

// config/bundles.php
return [
    // ...
    CronMonitor\Bridge\Symfony\CronMonitorBundle::class => ['all' => true],
];

Configure:

# config/packages/cron_monitor.yaml
cron_monitor:
    endpoint:        '%env(CRON_MONITOR_ENDPOINT)%'  # optional, defaults to SaaS
    timeout_seconds: 5.0
    retries:         1
    api_key:         '%env(CRON_MONITOR_API_KEY)%'   # optional
    messages:
        # FQCN of any message your Scheduler dispatches via Messenger.
        # The middleware ships start/success/fail pings for each one.
        App\Scheduler\Message\NightlyReportRun: 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'

Discover the FQCNs the bundle can see:

php bin/console cron-monitor:sync

It prints every RecurringMessage from every tagged scheduler.schedule_provider, plus a YAML snippet you can paste into the config above.

Plain bin/console commands

If your cron entry is a console command rather than a Scheduler run — e.g. * * * * * php bin/console app:reports:nightly straight out of crontab — either map the command name in YAML or declare the monitor UUID right on the command class, and the bundle wraps every invocation in start/success/fail pings via a kernel event subscriber.

YAML map (good for "configuration as deploy artefact" workflows):

cron_monitor:
    commands:
        'app:reports:nightly': 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'

Class attribute (good for "the UUID lives next to the code"):

use CronMonitor\Attribute\Monitor;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;

#[AsCommand(name: 'app:reports:nightly')]
#[Monitor(env: 'CRON_MONITOR_REPORTS_NIGHTLY_UUID')]
final class GenerateNightlyReportCommand extends Command
{
    // ...
}

Two attribute forms are supported:

  • #[Monitor(env: 'VAR_NAME')] — recommended for production. The attribute carries the env-var name; the SDK resolves the value at runtime via $_ENV → $_SERVER → getenv(). The UUID — a write capability secret — stays out of git history and rotates without a redeploy.
  • #[Monitor(uuid: '...literal...')] — convenient for local dev or one-off scripts where the UUID is not sensitive.

(#[Monitor(uuid: getenv('VAR'))] is a PHP parse error — attribute arguments must be compile-time constants — and '%env(VAR)%' is not expanded inside attribute payloads. The two-parameter shape is the intended way to thread env-sourced UUIDs into the attribute path.)

Both attribute sources are honoured, and YAML wins on conflict — so you can override either form per environment in YAML (e.g. 'app:reports:nightly': '%env(MY_UUID)%' with MY_UUID blank in dev to suppress monitoring without touching the attribute). A missing or empty env var is itself treated as deliberate suppression — same policy as an empty YAML map entry.

No code changes inside the command body. A non-zero exit fires fail; an uncaught throwable fires fail with the exception class, message, and file:line in the body so the cron-monitor dashboard shows the immediate cause without you tailing logs. A command stopped by a signal Symfony handles (SIGTERM, SIGINT, …) fires fail naming the signal, whatever its exit code.

Laravel scheduler integration

The service provider is auto-discovered. Publish the config:

php artisan vendor:publish --tag=cron-monitor-config

Then in routes/console.php, either pass the UUID at the call site or declare it on the command class and call ->monitor() without arguments:

use Illuminate\Support\Facades\Schedule;

// Explicit UUID
Schedule::command('reports:nightly')
    ->dailyAt('02:00')
    ->monitor('xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx');

// UUID lives on the command class
use App\Console\Commands\GenerateNightlyReportCommand;

Schedule::command(GenerateNightlyReportCommand::class)
    ->dailyAt('02:00')
    ->monitor();

For the no-arg form, the macro reads the #[Monitor] attribute on the Artisan command class behind the scheduled event. The attribute has two construction forms — env-sourced (recommended for production) and literal:

use CronMonitor\Attribute\Monitor;
use Illuminate\Console\Command;

// Recommended for production — UUID stays out of git history.
#[Monitor(env: 'CRON_MONITOR_REPORTS_NIGHTLY_UUID')]
final class GenerateNightlyReportCommand extends Command
{
    protected $signature = 'reports:nightly';

    // ...
}

// Or with a literal UUID — fine for local dev / one-off scripts.
#[Monitor(uuid: 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx')]

The env-sourced form resolves through $_ENV → $_SERVER → getenv() so it works across CLI, FPM, and container-injected env setups. A missing or empty env var is treated as deliberate suppression for the current environment.

Precedence rule: an explicit string argument to ->monitor(...) always wins over the attribute, and an empty string (->monitor(env('MY_UUID', ''))) is treated as explicit suppression for the current environment. The ->monitor(...) macro hooks before / onSuccess / onFailure so you get start/success/fail pings on the same boundary as the job execution.

php artisan cron-monitor:sync lists every scheduled command and emits a config/cron-monitor.php snippet.

Queued jobs

For ShouldQueue jobs dispatched outside the scheduler, attach the bundled job middleware:

use CronMonitor\Bridge\Laravel\Queue\MonitorQueueJob;

class GenerateNightlyReport implements ShouldQueue
{
    public function middleware(): array
    {
        return [MonitorQueueJob::withUuid('xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx')];
    }
}

Each invocation pings start, then success on completion or fail on a thrown exception (with the class, message, and file:line in the body). The underlying SDK swallows its own failures, so a flaky cron-monitor backend never breaks the queued job.

Standalone CLI

For plain cron / systemd timer users, the vendor/bin/cron-monitor binary needs no framework and no extra HTTP client — it sends over the SDK's bundled cURL transport, so composer require really is the only install step:

# Heartbeat from a one-liner cron entry
* * * * * /opt/scripts/run.sh && cron-monitor heartbeat $UUID

# Wrap a job with start/success/fail
cron-monitor start $UUID
if /opt/scripts/run.sh 2> /tmp/err; then
    cron-monitor success $UUID
else
    cron-monitor fail $UUID --body="$(cat /tmp/err)"
fi

Set CRON_MONITOR_ENDPOINT and CRON_MONITOR_API_KEY in the environment to avoid repeating the flags.

Signing up from the terminal

No cronheart.com account yet? The same binary creates one and prints its first API token. Append it to the env file your framework loads and git ignores, .env.local on Symfony or .env on Laravel:

git check-ignore -q .env.local && (umask 077; line=$(vendor/bin/cron-monitor signup 'you@example.com' --accept-terms | grep -E '^CRON_MONITOR_API_KEY=cmk_[A-Za-z0-9_-]+$') && printf '\n%s\n' "$line" >> .env.local)

--accept-terms states that you agree to the Terms of Service and the Privacy Policy; without it the command refuses and names both. A mail with one confirmation link goes to the address. The command shows a code such as BCDF-GHJK and waits, up to 30 minutes, while you open the link and type the code on that page. Once you confirm, it prints CRON_MONITOR_API_KEY=cmk_… on standard output, once, and exits 0; that is the variable the SDK reads its token from. Everything else goes to standard error, so the line above appends the token, on a line of its own and only when the signup succeeded, without it reaching the screen; the grep keeps anything else PHP might print, such as a startup warning, out of the file, umask 077 keeps a new file private, and git check-ignore stops the line before the signup starts when git would commit the file (outside a git repository, drop it and pick the file with care). Quote the address: it goes through the shell.

An address that already has an account never confirms (an active account gets a mail saying so): stop the command and create a token in the dashboard (Account → API tokens) instead. The new account has no password; to sign in on the web, use "Forgot password" on the sign-in page. Exit codes: 0 the token was printed, 2 the signup did not complete (the code expired, a throttle, or signup is switched off; the message says which), 64 a usage error. A signup always goes over HTTPS: a plain-HTTP --endpoint is refused.

Managing monitors via the API

Everything above is the ping path — anonymous, never throws, never breaks your job. Since 1.0.0 the SDK also ships an authenticated management client for listing and creating monitors from your own account, CronMonitor\Api\MonitorApiClient.

This client is the deliberate opposite of the ping client: it throws typed exceptions, because you call it from admin screens or CLI tooling where you want to know — and react — when something fails.

Authenticate with a Personal Access Token (cmk_…). Without an account, vendor/bin/cron-monitor signup creates one and prints its first token (Signing up from the terminal); with one, create a token in the cronheart.com dashboard (Account → API tokens). Every plan includes the API, Free too; requests are rate-limited per account — 30 a minute on Free, more on the paid tiers (120 Starter, 300 Growth, 600 Scale). The token rides on Configuration::apiKey:

use CronMonitor\Api\Dto\CreateMonitorRequest;
use CronMonitor\Api\Dto\ScheduleKind;
use CronMonitor\Api\Dto\Vocabulary;
use CronMonitor\Api\Exception\ApiException;
use CronMonitor\Api\Exception\RateLimitException;
use CronMonitor\Api\Exception\ValidationException;
use CronMonitor\Api\MonitorApiClient;
use CronMonitor\Client\Configuration;

$api = MonitorApiClient::create(
    Configuration::withDefaultEndpoint(apiKey: getenv('CRON_MONITOR_API_KEY') ?: null),
);

// List your monitors (paginated).
$page = $api->listMonitors(offset: 0, limit: 50);
foreach ($page->data as $monitor) {
    printf("%s  %s  %s\n", $monitor->uuid, Vocabulary::value($monitor->status), $monitor->name);
}

// Or walk every page lazily.
foreach ($api->allMonitors() as $monitor) {
    // ...
}

// Create one.
try {
    $monitor = $api->createMonitor(new CreateMonitorRequest(
        name: 'Nightly report',
        scheduleKind: ScheduleKind::Cron,
        scheduleExpr: '0 2 * * *',
        tz: 'UTC',
        graceSeconds: 120,
    ));
    echo $monitor->pingUrl;
} catch (ValidationException $e) {
    foreach ($e->errors as $field => $message) {
        echo "$field: $message\n";
    }
} catch (RateLimitException $e) {
    echo "Slow down; retry after {$e->retryAfter}s\n";
} catch (ApiException $e) {
    // Catch the base type for any other API failure (auth, limits, network…).
    error_log($e->getMessage());
}

In Symfony the client is autowired (MonitorApiClient); in Laravel it is a container singleton (app(MonitorApiClient::class)). Both read the token from the same api_key config you already set.

The full management surface (1.1.0)

1.1.0 rounds the client out to the backend's whole /api/v1 surface. Everything is additive — the 1.0.0 calls above are unchanged.

use CronMonitor\Api\Dto\CreateChannelRequest;
use CronMonitor\Api\Dto\SnoozeDuration;
use CronMonitor\Api\Dto\UpdateMonitorRequest;

// Edit / pause / resume / snooze / delete a monitor (by UUID).
$api->updateMonitor($uuid, new UpdateMonitorRequest(graceSeconds: 300));
$api->pauseMonitor($uuid);
$api->resumeMonitor($uuid);
$api->snoozeMonitor($uuid, SnoozeDuration::OneDay);
$api->deleteMonitor($uuid);          // 204, returns void

// Rotate a monitor's UUID (kills the old ping URL immediately).
$rotated = $api->rotateMonitorUuid($uuid);

// History: pings are cursor-paginated, alerts are offset-paginated.
foreach ($api->allPings($uuid) as $ping) {
    printf("%s  %s  %sms\n", Vocabulary::value($ping->kind), $ping->receivedAt->format('c'), $ping->runtimeMs ?? '-');
}
foreach ($api->allAlerts($uuid) as $alert) {
    printf("%s  %s\n", Vocabulary::value($alert->kind), $alert->createdAt->format('c'));
}

// Channels: create / rename / delete / test / rotate the signing secret.
// Channel ids are strings (the backend id is a BIGINT) — pass $channel->id straight back.
$channel = $api->createChannel(CreateChannelRequest::email('Ops inbox', 'ops@example.com'));
$api->updateChannel($channel->id, 'Renamed');
$result = $api->testChannel($channel->id);   // sends a real test alert (see "Channel-test outcomes")
$secret = $api->rotateChannelSecret($webhookChannelId);  // plaintext returned ONCE

// Account: plan, monitor budget and live rate-limit standing in one read.
$account = $api->getAccount();
printf("Plan %s — %d/%d monitors used\n", Vocabulary::value($account->plan->key), $account->monitorBudget->used, $account->plan->monitorLimit);

Vocabularies are open on read. Monitor::$status, Monitor::$scheduleKind, Ping::$kind, Alert::$kind and Plan::$key are typed Enum|string: you get the enum case for a value this SDK version knows, and the server's verbatim string for one it does not. A status added server-side therefore cannot make your monitor reads start failing — including for code that never looks at the field. Compare with === as before, use Vocabulary::value() when you just want the string, and narrow with instanceof (or match with a default) before branching on behaviour. Writing stays closed: a request DTO accepts nothing but a real enum case, so a passed-through value cannot be sent back as if the SDK understood it. Wrongly typed fields still fail the read loudly.

Teams, Google Chat and PagerDuty channels (1.6.0). Three more kinds, each with a named constructor on CreateChannelRequest:

$api->createChannel(CreateChannelRequest::teams('Ops Teams', $teamsWorkflowUrl));
$api->createChannel(CreateChannelRequest::googleChat('Ops space', $googleChatWebhookUrl));
$api->createChannel(CreateChannelRequest::pagerDuty('On call', $integrationKey));

Teams and Google Chat take a webhook_url; PagerDuty takes the 32-letter-and-digit Integration Key of an Events API v2 integration, sent as routing_key. The SDK checks only that the value is present: the host, length and pattern are the server's to judge, and a rejection arrives as a ValidationException. The webhook URL and the routing key are credentials; the server redacts them in every response, print_r() and var_dump() of the request mask them, and a channel of any kind this SDK version has no case for still reads back, with its kind as the raw string in Channel::$kind.

Reading a monitor's alert routing (1.3.0). Every monitor a read returns carries channels — the notification channels it alerts, in the backend's id order:

use CronMonitor\Api\Dto\MonitorChannel;
use CronMonitor\Api\Dto\UpdateMonitorRequest;

$monitor = $api->getMonitor($uuid);
foreach ($monitor->channels as $channel) {
    printf("%s (%s)\n", $channel->label, $channel->kind);
}

// Copy one monitor's routing onto another: MonitorChannel::$id is exactly what
// channelIds accepts, so nothing needs converting.
//
// Guard the empty case. `channel_ids: []` is not a no-op — it REPLACES the
// target's routing with none, so copying from a monitor that reports no
// channels would leave the target alerting nobody.
if ([] !== $monitor->channels) {
    $api->updateMonitor($otherUuid, new UpdateMonitorRequest(
        channelIds: array_map(static fn (MonitorChannel $c): string => $c->id, $monitor->channels),
    ));
}

An empty channels means the monitor alerts nobody — there is no fan-out — or that the backend predates this field, which older deployments omit entirely (as does an idempotent create replaying a body stored before it). The two are indistinguishable, which is exactly why the guard above matters.

Note the field reports what is attached, not what gets delivered. Attachment is necessary but not sufficient: the backend also skips a channel that isn't verified, any delivery while the monitor is snoozed or paused, and a channel kind an operator has switched off. So don't read "non-empty channels" as "alerts will land" — Channel::$verified ($api->getChannel($channel->id)) plus Monitor::$snoozedUntil / $status narrow it down, and Alert::$dispatchedTo on a past alert is the only per-channel evidence of an actual send. MonitorChannel is a slim view (id, kind, label); fetch the channel itself for verified and config.

Channel-test outcomes (1.2.0). A test actually delivers. When the request reaches the backend cleanly but the destination rejects or fails the delivery, testChannel() throws ChannelDeliveryException carrying HTTP 502 — a subclass of UnexpectedResponseException, so any catch (UnexpectedResponseException) / catch (ApiException) written against 1.1.0 still catches it; catch the narrower type to tell a failed destination delivery apart from any other bad gateway. An unverified or transport-less channel throws ValidationException (HTTP 422) instead.

Retries are per-verb. Reads and idempotent transitions (PATCH / DELETE / pause / resume / snooze) retry within Configuration::retries; creates, UUID/secret rotation and channel tests never auto-retry, because a blind replay would duplicate, re-rotate, or double-send. To make a create safe to retry, pass an idempotency key — the backend dedups a replay carrying the same key and body:

$api->createMonitor($request, idempotencyKey: 'deploy-2026-06-19-nightly');

Signing up from code (1.5.0). The two calls behind cron-monitor signup are on the client too, for a setup wizard or an installer of your own. They take no token:

use CronMonitor\Api\Exception\RateLimitException;
use CronMonitor\Api\MonitorApiClient;

$api = MonitorApiClient::create();

$started = $api->startSignup('you@example.com', acceptTerms: true);
// Server text on a terminal: strip control and format characters first.
$code = preg_replace('/[\p{Cc}\p{Cf}]+/u', ' ', $started->userCode)
    ?? preg_replace('/[^\x20-\x7E]+/', ' ', $started->userCode);
echo "Open the mail and type {$code} on the page it links to.\n";

$waited = 0;
$wait = $started->interval;
while ($waited < $started->expiresIn) {
    sleep($wait);
    $waited += $wait;
    $wait = $started->interval;
    try {
        $token = $api->pollSignupToken($started->deviceCode);
    } catch (RateLimitException $e) {
        $wait = max($started->interval, $e->retryAfter ?? $started->interval);
        continue;
    }
    if (null !== $token) {
        storeSecret('CRON_MONITOR_API_KEY', $token->token); // returned once; never log it
        break;
    }
}

acceptTerms: true states that the person agreed to the terms and the privacy policy; false is refused before any request. Both calls send no token even when one is configured, refuse a plain-HTTP endpoint, and are never retried automatically: a replayed start mails the address again, and the poll loop is the retry. $started->deviceCode is a secret, since whoever holds it can claim the token once the person confirms; keep it out of output and logs. A 410 is SignupExpiredException, a subclass of UnexpectedResponseException: the request expired, was cancelled or was already claimed, and its detail says how to recover. Like any text from the server, userCode and detail are cleaned of control characters before they reach a terminal, as above.

Auto-creating monitors from your scheduler

The cron-monitor:sync command (Symfony and Laravel) lists your scheduled jobs. By default it just prints the config snippet to wire UUIDs by hand — no credentials, no network. With --apply it reconciles those jobs against your account by name and creates the missing ones via the API:

# Preview what would be created (lists your monitors, writes nothing):
bin/console cron-monitor:sync --dry-run            # Symfony
php artisan cron-monitor:sync --dry-run            # Laravel

# Actually create the missing monitors, optionally routing them to a channel:
bin/console cron-monitor:sync --apply --channel=7

Reconciliation is by name, so renaming a job creates a second monitor rather than renaming the first — rename on the dashboard too, or delete the orphan. Two jobs that share a name are reported as a conflict and neither is created (give them distinct names). --apply / --dry-run need an API key; only cron-expressed jobs are auto-created (interval/closure jobs are reported skipped — create those by hand).

The Laravel bridge carries each event's timezone into the created monitor. The Symfony Scheduler exposes no per-trigger timezone, so Symfony-synced monitors are created in UTC — if a Symfony schedule runs in another zone, set the monitor's timezone on the dashboard after creating it.

Configuration knobs

Setting Default Notes
endpoint https://cronheart.com Self-hosted: point at your install.
timeout_seconds 5.0 Per-request, low by design.
retries 1 Pings are idempotent server-side.
api_key null Personal Access Token (cmk_…) for the management API, issued on every plan; not needed for pings.
allow_insecure_endpoint false Required for http:// endpoints. Refused outright when api_key is set.

Security

  • HTTPS is required by default. The SDK refuses to send pings to plain HTTP unless allow_insecure_endpoint: true is explicitly set.
  • allow_insecure_endpoint covers anonymous ping-only installs only. Combining it with an api_key is a constructor error: the per-monitor UUID on the wire is one monitor's credential, an account-wide token in cleartext is the whole account's.
  • Neither credential reaches your logs. The ping client logs a truncated SHA-256 of the monitor UUID (the same digest the server logs, so the two join during an incident), and the management client reports the route it called — /api/v1/monitors/{uuid} — rather than the resolved path.
  • The per-monitor UUID is treated as a write credential and must be a canonical UUID, nothing around it, before it is concatenated into a URL. The action segment must be one of run, start, success, ok, fail (case-insensitive) or 1 to 16 digits such as an exit code, the segments the service stores a ping for, so no path traversal and not even a trailing newline reaches the path. A rejected value is not echoed.
  • The Authorization: Bearer <api_key> header is attached only when an API key is configured; nothing is sent for anonymous installs.
  • The API token (api_key) is a full account credential, far more powerful than a per-monitor UUID — it can list and create every monitor on the account. Keep it in an environment variable / secrets store, never commit it, and never log it. Ping-only installs do not need it at all; leave it unset.
  • fail pings include exception text and a host file path. When a monitored handler throws, the SDK sends the exception class name, getMessage(), and file:line of the throw site to the cron-monitor endpoint (capped at 10 KB). Exception messages routinely embed attacker-controlled input (e.g. PDOException SQL fragments, validation errors echoing user data); the file path discloses the host deployment layout. If your threat model treats either as sensitive, wrap the host job in a try/catch that throws a sanitised exception, or call CronMonitorClient::fail($uuid, $body) directly with a curated body.

For coding agents

AGENTS.md at the repository root points to skills/add-cronheart/SKILL.md: a numbered recipe a coding agent (or a person in a hurry) follows to wire this SDK into an application. It detects Symfony or Laravel, installs the package, sets the configuration keys, attributes every scheduled task to a monitor, creates the monitors through cron-monitor:sync or the management client with a Personal Access Token, verifies the first ping and explains what each exception means. Claude Code loads it as a skill when the add-cronheart folder is copied into the application's .claude/skills/; any other agent can be pointed at the file. The recipe carries placeholder UUIDs and tokens only, and the test suite checks that every command it names is one the bundle registers. skills/ and AGENTS.md are repository content: .gitattributes marks both export-ignore, so a composer require install (which downloads the VCS host's archive of the tagged commit) never receives them.

Development

composer install
composer test          # PHPUnit
composer stan          # PHPStan level 8
composer cs-check      # php-cs-fixer dry-run

License

MIT — see LICENSE.

Related Packages

nunomaduro/laravel-console-task

Laravel Console Task is a output method for your Laravel/Laravel Zero commands.

2,591,324 259
nunomaduro/laravel-console-summary

A Beautiful Laravel Console Summary for your Laravel/Laravel Zero commands.

2,546,755 67
nunomaduro/collision

Cli error handling for console/command-line PHP applications.

394,964,158 4,660
nunomaduro/laravel-console-menu

Laravel Console Menu is an output method for your Laravel/Laravel Zero commands.

453,107 812
nunomaduro/laravel-console-dusk

Laravel Console Dusk allows the usage of Laravel Dusk in Laravel/Laravel Zero ar...

61,080 164

Version History

Version Released PHP License
v1.5.5 >=8.2 MIT
v1.5.4 >=8.2 MIT
v1.5.3 >=8.2 MIT
v1.5.2 >=8.2 MIT
v1.5.1 >=8.2 MIT