Unified Cron Framework And Monitoring
MVP setup for project cron jobs using IP-restricted cron.php and public Table methods.
model and action in config/cron.php.
Cron target
One physical webroot/cron.php endpoint per project.
Business logic
Public Table methods return STATUS and MSG.
Security
Execution is IP-restricted for MVP. No token is required.
Core Files
| File | Purpose |
|---|---|
sourceFiles/webroot/cron.php |
Permanent physical hosting endpoint. |
sourceFiles/src/Service/CronService.php |
Loads config, authorizes IPs, runs model actions, and writes heartbeats. |
sourceFiles/config/cron.php |
Project-specific job registry. |
Setup Flow
- Create or update the project Table method.
- Wire an admin-prefix action or button to that Table method.
- Run the admin action manually and confirm the result.
- Add the job entry to
sourceFiles/config/cron.php. - Confirm
cron.php?action=run&job=email_queueworks from an allowed IP. - Point hosting at
cron.php?action=run_all.
1. Project config: sourceFiles/config/cron.php
Each project registers its own jobs here. Hosting cadence stays outside the MVP framework.
<?php
return [
'status_path' => env('CRON_STATUS_PATH') ?: sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'setupcase-cron',
'security' => [
'allowed_ips' => [
'127.0.0.1',
],
],
'jobs' => [
'email_queue' => [
'enabled' => true,
'model' => 'EmailQueues',
'action' => 'processCronQueue',
'description' => 'Process queued emails.',
'schedule' => 'Every time cron.php?action=run_all is called.',
'max_age' => 300,
'monitor' => true,
],
],
];
2. Table method: sourceFiles/src/Model/Table/EmailQueuesTable.php
Put the business logic in the Table method. Admin and cron should both call this method.
public function processCronQueue(): array
{
/*
* Put project-specific queue processing here.
* Keep the public method short and move detail into private helpers.
*/
return [
'STATUS' => 200,
'MSG' => 'Processed email queue',
];
}
3. Admin manual run action
Use an admin-prefix action or button to prove the Table method works before adding it to cron.
public function processCronQueue()
{
$response = $this->EmailQueues->processCronQueue();
if (($response['STATUS'] ?? 500) === 200) {
$this->Flash->success($response['MSG'] ?? 'Cron job completed.');
} else {
$this->Flash->error($response['MSG'] ?? 'Cron job failed.');
}
return $this->redirect($this->referer());
}
4. Admin button
Point the button at the admin action. The admin action calls the same Table method that cron will call.
<?php echo $this->Form->postLink(
'Run Email Queue',
[
'prefix' => 'Admin',
'controller' => 'EmailQueues',
'action' => 'processCronQueue',
],
[
'class' => 'btn btn-primary',
'confirm' => 'Run this cron job now?',
]
); ?>
5. Hosting cron URL
Hosting calls one permanent physical endpoint. IP restriction protects execution for the MVP.
https://example.com/cron.php?action=run_all
6. Manual verification URLs
Use these during setup from an allowed IP.
https://example.com/cron.php?action=status
https://example.com/cron.php?action=run&job=email_queue
https://example.com/cron.php?action=run_all
Project config
<?php
declare(strict_types=1);
return [
'status_path' => '/absolute/path/to/status/dir/',
'jobs' => [
'email_queue' => [
'enabled' => true,
'model' => 'EmailQueues',
'action' => 'processCronQueue',
'description' => 'Process queued emails.',
'schedule' => 'Every time cron.php?action=run_all is called.',
'max_age' => 300,
'monitor' => true,
],
],
];
Service
<?php
declare(strict_types=1);
namespace App\Service;
use Cake\Core\Configure;
use Cake\ORM\Table;
use Cake\ORM\TableRegistry;
use DateTimeImmutable;
use DateTimeInterface;
use Throwable;
class CronService
{
private array $config;
private array $jobs;
private string $statusPath;
private DateTimeImmutable $generatedAt;
private bool $debug;
public function __construct(?array $config = null, ?DateTimeImmutable $generatedAt = null)
{
$this->debug = (bool)Configure::read('debug');
$this->generatedAt = $generatedAt ?: new DateTimeImmutable();
$this->config = $this->normalizeConfig($config === null ? $this->loadConfig() : $config);
$this->jobs = $this->config['jobs'];
$this->statusPath = $this->config['status_path'];
}
public function run(string $jobKey): array
{
if (!$this->canRunJob($jobKey)) {
return $this->notFoundResponse();
}
return $this->run_executeJob($jobKey, $this->jobs[$jobKey]);
}
public function runAll(): array
{
$results = [];
foreach ($this->jobs as $jobKey => $job) {
$jobKey = (string)$jobKey;
if ($this->jobIsEnabled($job)) {
$results[$jobKey] = $this->runAll_runJob($jobKey, $job);
}
}
return $this->runAll_response($results);
}
public function status(): array
{
$jobs = [];
foreach ($this->jobs as $jobKey => $job) {
$jobs[$jobKey] = $this->status_buildJobStatus((string)$jobKey, $job);
}
return $this->status_response($jobs);
}
private function loadConfig(): array
{
$file = CONFIG . 'cron.php';
if (!is_file($file)) {
return [];
}
$config = require $file;
return is_array($config) ? $config : [];
}
private function normalizeConfig(array $config): array
{
$config += [
'status_path' => sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'setupcase-cron',
'jobs' => [],
];
$config['status_path'] = is_string($config['status_path']) && $config['status_path'] !== ''
? $config['status_path']
: sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'setupcase-cron';
$config['jobs'] = $this->normalizeConfig_jobs($config['jobs']);
return $config;
}
private function normalizeConfig_jobs($jobs): array
{
if (!is_array($jobs)) {
return [];
}
foreach ($jobs as $jobKey => $job) {
$jobs[$jobKey] = is_array($job) ? $job : [];
}
return $jobs;
}
private function canRunJob(string $jobKey): bool
{
if (!$this->isValidJobKey($jobKey) || empty($this->jobs[$jobKey])) {
return false;
}
return $this->jobIsEnabled($this->jobs[$jobKey]);
}
private function run_executeJob(string $jobKey, array $job): array
{
try {
$result = $this->normalizeJobResult($this->run_callJob($job));
$heartbeatWritten = false;
if ($this->isSuccessResponse($result)) {
$heartbeatWritten = $this->writeHeartbeat($jobKey);
$result = $this->run_mergeHeartbeatResult($result, $heartbeatWritten);
}
return $result + ['heartbeat_written' => $heartbeatWritten];
} catch (Throwable $e) {
return $this->exceptionResponse('Cron job failed.', $e) + ['heartbeat_written' => false];
}
}
private function runAll_runJob(string $jobKey, array $job): array
{
if (!$this->isValidJobKey($jobKey)) {
return [
'STATUS' => 500,
'MSG' => 'Invalid cron job key.',
'heartbeat_written' => false,
];
}
return $this->run_executeJob($jobKey, $job);
}
private function run_callJob(array $job)
{
$model = (string)($job['model'] ?? '');
$action = (string)($job['action'] ?? '');
$table = $this->getTable($model);
if ($action === '' || !$this->isPublicTableAction($table, $action)) {
throw new \RuntimeException('Cron job action is not callable.');
}
return $table->{$action}();
}
private function isPublicTableAction(Table $table, string $action): bool
{
if (!method_exists($table, $action)) {
return false;
}
return (new \ReflectionMethod($table, $action))->isPublic();
}
private function getTable(string $model): Table
{
if ($model === '') {
throw new \RuntimeException('Cron job model is missing.');
}
return TableRegistry::getTableLocator()->get($model, ['allowFallbackClass' => false]);
}
private function normalizeJobResult($result): array
{
if (!is_array($result)) {
return ['STATUS' => 500, 'MSG' => 'Cron job did not return a response array.'];
}
$result['STATUS'] = (int)($result['STATUS'] ?? 500);
$result['MSG'] = (string)($result['MSG'] ?? 'Cron job completed.');
return $result;
}
private function run_mergeHeartbeatResult(array $result, bool $heartbeatWritten): array
{
if ($heartbeatWritten) {
return $result;
}
return [
'STATUS' => 500,
'MSG' => 'Cron job completed but heartbeat could not be written.',
];
}
private function runAll_response(array $results): array
{
return [
'generated_at' => $this->generatedAt->format(DateTimeInterface::ATOM),
'STATUS' => 200,
'MSG' => 'Execution loop completed',
'total_jobs' => count($this->jobs),
'executed_jobs' => count($results),
'successful_jobs' => $this->countResults($results, true),
'failed_jobs' => $this->countResults($results, false),
'jobs' => $results,
];
}
private function countResults(array $results, bool $success): int
{
$count = 0;
foreach ($results as $result) {
if ($this->isSuccessResponse($result) === $success) {
$count++;
}
}
return $count;
}
private function status_buildJobStatus(string $jobKey, array $job): array
{
$status = $this->status_baseJobStatus($jobKey, $job);
if (!$this->isValidJobKey($jobKey)) {
return $status + [
'healthy' => false,
'last_success' => null,
'age_seconds' => null,
'MSG' => 'Invalid cron job key.',
];
}
if (!$this->jobIsEnabled($job) || empty($job['monitor'])) {
return $status + ['healthy' => null, 'last_success' => null, 'age_seconds' => null];
}
return $status + $this->status_heartbeatStatus($jobKey, $job);
}
private function status_baseJobStatus(string $jobKey, array $job): array
{
return [
'enabled' => $this->jobIsEnabled($job),
'command' => $this->status_jobCommand($jobKey),
'description' => (string)($job['description'] ?? ''),
'schedule' => (string)($job['schedule'] ?? ''),
'max_age' => (int)($job['max_age'] ?? 0),
'monitor' => !empty($job['monitor']),
];
}
private function status_heartbeatStatus(string $jobKey, array $job): array
{
$lastSuccess = $this->readHeartbeat($jobKey);
if ($lastSuccess === null) {
return ['healthy' => false, 'last_success' => null, 'age_seconds' => null];
}
$age = $this->generatedAt->getTimestamp() - $lastSuccess->getTimestamp();
return [
'healthy' => $age <= (int)$job['max_age'],
'last_success' => $lastSuccess->format(DateTimeInterface::ATOM),
'age_seconds' => $age,
];
}
private function status_response(array $jobs): array
{
$enabledJobs = array_filter($jobs, static fn(array $job): bool => $job['enabled']);
$monitoredJobs = array_filter($jobs, static fn(array $job): bool => $job['enabled'] && $job['monitor']);
$healthyJobs = array_filter($monitoredJobs, static fn(array $job): bool => $job['healthy'] === true);
$failedJobKeys = array_keys(array_filter($monitoredJobs, static fn(array $job): bool => $job['healthy'] !== true));
$failedJobs = count($monitoredJobs) - count($healthyJobs);
return [
'STATUS' => $failedJobs === 0 ? 200 : 500,
'MSG' => $failedJobs === 0 ? 'Cron jobs healthy' : 'Cron jobs unhealthy',
'healthy' => $failedJobs === 0,
'failed_jobs' => $failedJobs,
'failed_jobs_text' => implode(', ', $failedJobKeys),
'generated_at' => $this->generatedAt->format(DateTimeInterface::ATOM),
'status_path' => $this->statusPath,
'available_commands' => $this->status_availableCommands($jobs),
'total_jobs' => count($jobs),
'enabled_jobs' => count($enabledJobs),
'monitored_jobs' => count($monitoredJobs),
'healthy_jobs' => count($healthyJobs),
'jobs' => $jobs,
];
}
private function status_availableCommands(array $jobs): array
{
return [
'status' => './bin/cake cron status',
//'note' => 'Use one command per server cron entry when scheduling jobs separately.',
'run_all' => $this->status_separateJobCommands($jobs),
];
}
private function status_separateJobCommands(array $jobs): array
{
$commands = [];
foreach ($jobs as $jobKey => $job) {
$commands[$jobKey] = [
'enabled' => !empty($job['enabled']),
'command' => $this->status_jobCommand((string)$jobKey),
];
}
return $commands;
}
private function status_jobCommand(string $jobKey): string
{
return './bin/cake cron run ' . $jobKey;
}
private function writeHeartbeat(string $jobKey): bool
{
if (!$this->ensureStatusPath()) {
return false;
}
$timestamp = $this->generatedAt->format(DateTimeInterface::ATOM);
return @file_put_contents($this->heartbeatPath($jobKey), $timestamp) !== false;
}
private function readHeartbeat(string $jobKey): ?DateTimeImmutable
{
$file = $this->heartbeatPath($jobKey);
if (!is_readable($file)) {
return null;
}
$contents = trim((string)@file_get_contents($file));
if ($contents === '') {
return null;
}
try {
return new DateTimeImmutable($contents);
} catch (Throwable $e) {
return null;
}
}
private function ensureStatusPath(): bool
{
if (is_dir($this->statusPath)) {
return is_writable($this->statusPath);
}
return @mkdir($this->statusPath, 0775, true) && is_writable($this->statusPath);
}
private function heartbeatPath(string $jobKey): string
{
return rtrim($this->statusPath, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $jobKey . '.status';
}
private function jobIsEnabled(array $job): bool
{
return !empty($job['enabled']);
}
private function isValidJobKey(string $jobKey): bool
{
return (bool)preg_match('/^[a-z0-9_]+$/', $jobKey);
}
private function isSuccessResponse(array $result): bool
{
$status = (int)($result['STATUS'] ?? 500);
return $status >= 200 && $status < 300;
}
private function notFoundResponse(): array
{
return ['STATUS' => 404, 'MSG' => 'Not found'];
}
private function exceptionResponse(string $message, Throwable $e): array
{
if ($this->debug) {
$message .= ' ' . $e->getMessage();
}
return ['STATUS' => 500, 'MSG' => $message];
}
}