PHPackages                             cron-monitor/php-sdk - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [HTTP &amp; Networking](/categories/http)
4. /
5. cron-monitor/php-sdk

ActiveLibrary[HTTP &amp; Networking](/categories/http)

cron-monitor/php-sdk
====================

PHP SDK for cronheart.com (cron-monitor) with first-class Symfony Scheduler and Laravel Scheduler support.

v1.2.0(3w ago)07671MITPHPPHP &gt;=8.2CI passing

Since May 14Pushed 3w agoCompare

[ Source](https://github.com/alexander-po/cron-monitor-php)[ Packagist](https://packagist.org/packages/cron-monitor/php-sdk)[ Docs](https://cronheart.com)[ RSS](/packages/cron-monitor-php-sdk/feed)WikiDiscussions main Synced 3w ago

READMEChangelogDependencies (38)Versions (19)Used By (1)

cron-monitor PHP SDK
====================

[](#cron-monitor-php-sdk)

Composer SDK for [cronheart.com](https://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](https://github.com/alexander-po/cron-monitor-php/actions/workflows/ci.yml/badge.svg)](https://github.com/alexander-po/cron-monitor-php/actions/workflows/ci.yml)[![Latest Version](https://camo.githubusercontent.com/2cde8f0f1e29f44ebb932c4e4c140e1d4ebabd80526ad5d135ffa05f8eae053b/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f63726f6e2d6d6f6e69746f722f7068702d73646b2e737667)](https://packagist.org/packages/cron-monitor/php-sdk)[![Monthly Downloads](https://camo.githubusercontent.com/a151d25c8bb5a38f76baf6f97a30bbc907c79f0231f57b5ff306b2aee8490707/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f646d2f63726f6e2d6d6f6e69746f722f7068702d73646b2e737667)](https://packagist.org/packages/cron-monitor/php-sdk)[![PHP Version](https://camo.githubusercontent.com/e088fcd9e3c23821e1a6068c6d64b08f19678edf19fd0be1b0cecb711f76bfcb/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f7068702d762f63726f6e2d6d6f6e69746f722f7068702d73646b2e737667)](https://packagist.org/packages/cron-monitor/php-sdk)[![License: MIT](https://camo.githubusercontent.com/fdf2982b9f5d7489dcf44570e714e3a15fce6253e0cc6b5aa61a075aac2ff71b/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4c6963656e73652d4d49542d79656c6c6f772e737667)](LICENSE)

Why
---

[](#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
-----------------

[](#whats-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
-------

[](#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)
--------------------------------

[](#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
-----------------------------

[](#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

[](#plain-binconsole-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.

Laravel scheduler integration
-----------------------------

[](#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

[](#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
--------------

[](#standalone-cli)

For plain cron / systemd timer users, the `vendor/bin/cron-monitor`binary doesn't require any framework:

```
# 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.

Managing monitors via the API
-----------------------------

[](#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_…`) created in the cronheart.com dashboard (Settings → API Tokens). API access requires a Starter plan or higher. The token rides on `Configuration::apiKey`:

```
use CronMonitor\Api\Dto\CreateMonitorRequest;
use CronMonitor\Api\Dto\ScheduleKind;
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, $monitor->status->value, $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, plan, 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)

[](#the-full-management-surface-110)

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", $ping->kind->value, $ping->receivedAt->format('c'), $ping->runtimeMs ?? '-');
}
foreach ($api->allAlerts($uuid) as $alert) {
    printf("%s  %s\n", $alert->kind->value, $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", $account->plan->key->value, $account->monitorBudget->used, $account->plan->monitorLimit);
```

**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');
```

### Auto-creating monitors from your scheduler

[](#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
-------------------

[](#configuration-knobs)

SettingDefaultNotes`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; not needed for pings.`allow_insecure_endpoint``false`Required for `http://` endpoints.Security
--------

[](#security)

- HTTPS is required by default. The SDK refuses to send pings to plain HTTP unless `allow_insecure_endpoint: true` is explicitly set.
- The per-monitor UUID is treated as a write credential and is validated against the canonical UUID v4 shape before being concatenated into a URL — no path traversal via the action segment.
- The `Authorization: Bearer ` 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.

Development
-----------

[](#development)

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

License
-------

[](#license)

MIT — see [`LICENSE`](LICENSE).

###  Health Score

47

—

FairBetter than 93% of packages

Maintenance94

Actively maintained with recent releases

Popularity19

Limited adoption so far

Community10

Small or concentrated contributor base

Maturity55

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~6 days

Total

9

Last Release

26d ago

Major Versions

v0.2.1 → v1.0.02026-06-09

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/10212431?v=4)[Alexander Palazok](/maintainers/alexander-po)[@alexander-po](https://github.com/alexander-po)

---

Top Contributors

[![alexander-po](https://avatars.githubusercontent.com/u/10212431?v=4)](https://github.com/alexander-po "alexander-po (27 commits)")

---

Tags

schedulerconsolesymfonylaravelmonitoringpsr-18artisancronhealthcheckdeadman-switch

###  Code Quality

TestsPHPUnit

Static AnalysisPHPStan

Code StylePHP CS Fixer

Type Coverage Yes

### Embed Badge

![Health badge](/badges/cron-monitor-php-sdk/health.svg)

```
[![Health](https://phpackages.com/badges/cron-monitor-php-sdk/health.svg)](https://phpackages.com/packages/cron-monitor-php-sdk)
```

###  Alternatives

[tempest/framework

The PHP framework that gets out of your way.

2.2k34.4k16](/packages/tempest-framework)[flow-php/flow

PHP ETL - Extract Transform Load - Data processing framework

85036.3k](/packages/flow-php-flow)[drupal/core-recommended

Locked core dependencies; require this project INSTEAD OF drupal/core.

6942.5M425](/packages/drupal-core-recommended)[shopware/core

Shopware platform is the core for all Shopware ecommerce products.

585.6M600](/packages/shopware-core)[web-auth/webauthn-framework

FIDO2/Webauthn library for PHP and Symfony Bundle.

515100.5k3](/packages/web-auth-webauthn-framework)[aedart/athenaeum

Athenaeum is a mono repository; a collection of various PHP packages

255.2k](/packages/aedart-athenaeum)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
