Lire en français : README.fr.md.
Integrates a healthchecks.io-compatible cron monitoring instance
(whether the official service or a self-hosted instance, designated via base_url) into a Symfony
application using the Scheduler component: pings the
start/success/failure/log/exit-code of every scheduled run, and automatically syncs the corresponding
check's schedule (schedule, timezone, grace, tags).
- PHP >= 8.5
- Symfony 8.x
symfony/http-client, with an HTTP client enabled (framework.http_client)
composer require kibatic/cronitoring-bundle# config/packages/cronitoring.yaml
cronitoring:
base_url: 'https://cron.example.com' # required — healthchecks.io-compatible instance
# # (self-hosted or not) to use
ping_key: '%env(CRONITORING_PING_KEY)%' # ping-key of the project on the target instance
api_key: '%env(CRONITORING_API_KEY)%' # (read-write) API key for the same project
slug_prefix: '%env(CRONITORING_SLUG_PREFIX)%' # slug prefix, to distinguish installationsbase_url is required: this bundle points to no instance by default, each consuming project must
explicitly declare which one it wants to use. ping_key, api_key and slug_prefix remain optional.
As long as ping_key and api_key are not set, the bundle makes no network call at all; slug_prefix
defaults to an empty string, which is a perfectly valid configuration (it never gates any network call).
use Kibatic\CronitoringBundle\Attribute\AsMonitoredCronTask;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
#[AsCommand(name: 'app:my-task')]
#[AsMonitoredCronTask('*/5 * * * *', slug: 'my-task')]
class MyTaskCommand extends Command
{
// ...
}The slug is optional: when omitted, it is derived from the command's name and its arguments.
#[AsMonitoredCronTask] is only honored on a Console command class (#[AsCommand]): placed on an
invokable service scheduled via #[AsPeriodicTask] or a service method call, it silently has no effect.
For any of this to actually run, a messenger:consume <schedule-transport-name> worker (e.g.
scheduler_default) must be running: this bundle only wires the listener, but it's Symfony's Scheduler
itself that requires an active consumer.
#[AsMonitoredCronTask] extends the Scheduler component's #[AsCronTask]: its arguments are evaluated
by PHP itself, at compile time, as a constant expression (literals, new, class constants...). The PHP
engine has no notion of Symfony container parameters, so #[AsMonitoredCronTask('%env(MY_CRON)%')] does
not work: %env(...)% is only interpreted in config files (YAML/XML/PHP) when the container is built,
never in an attribute. Written as-is, it would just be a literal string sent to Cron\CronExpression,
which would fail.
To make a schedule configurable, it therefore has to be built by hand in the application's
ScheduleProviderInterface (#[AsSchedule]), injecting the env var through regular autowiring — a
service constructor, unlike an attribute, does resolve %env(...)%:
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Messenger\Message\RunCommandMessage;
use Symfony\Component\Scheduler\Attribute\AsSchedule;
use Symfony\Component\Scheduler\RecurringMessage;
use Symfony\Component\Scheduler\Schedule as SymfonySchedule;
use Symfony\Component\Scheduler\ScheduleProviderInterface;
#[AsSchedule]
class Schedule implements ScheduleProviderInterface
{
public function __construct(
#[Autowire('%env(MY_TASK_CRON)%')] private string $myTaskCron,
) {
}
public function getSchedule(): SymfonySchedule
{
return (new SymfonySchedule())
->add(RecurringMessage::cron(
$this->myTaskCron,
new RunCommandMessage('app:my-task'),
));
}
}In that case, remove #[AsMonitoredCronTask(...)] from the command class: otherwise it would be
scheduled twice (once via the attribute, once via the manual addition). Cronitoring monitoring (ping +
sync) stays active: this bundle's listener reacts to any RunCommandMessage processed by the Scheduler,
whether it was declared via attribute or added programmatically — only slug resolution differs (derived
from the command name in both cases, since there is no attribute to read).
Tasks declared via #[AsMonitoredCronTask] elsewhere in the code keep showing up automatically in the
final schedule, whatever getSchedule() does otherwise: it's AddScheduleMessengerPass, the Scheduler
component's compiler pass, that adds them there.