Scheduled Tasks
Plugins can register recurring tasks that the TaskRunner processes via the CLI — sitemap generation, session cleanup, expiry checks, re-indexing, and so on. This page covers registration in bootstrap.php and the script pattern.
Registration via $this->scheduledTasks
Inside the plugin's install():
public function install()
{
// ...
$this->scheduledTasks = [
'{
"name": "Session Cleanup",
"script": "session_cleanup.php",
"interval": 300,
"description": "Removes inactive sessions older than 2 hours"
}',
'{
"name": "Generate Sitemap",
"script": "generate_sitemap.php",
"interval": 86400,
"description": "Generates XML sitemap with hreflang from database"
}'
];
}Each entry is a JSON string with four fields:
| Field | Required | Purpose |
|---|---|---|
name | yes | UI label in the Task Manager |
script | yes | File name relative to {plugin}/scheduledTasks/ |
interval | yes | Interval in seconds (300 = 5 min, 3600 = 1 h, 86400 = 24 h). The alias interval_seconds is also accepted |
description | no | Text in the Task Manager (tooltip/info) |
Real example from the scheduledTasks plugin (_public/extensions/core/backend/scheduledTasks/bootstrap.php):
$this->scheduledTasks = [
'{"name": "Session Cleanup", "script": "session_cleanup.php", "interval": 300, "description": "Removes inactive sessions older than 2 hours"}',
'{"name": "Generate Sitemap", "script": "generate_sitemap.php", "interval": 86400, "description": "Generates XML sitemap with hreflang from database"}',
'{"name": "Publish Scheduled Pages", "script": "publish_scheduled_pages.php", "interval": 300, "description": "Publishes pages with scheduled publish times that have passed"}'
];On installPlugin(), the core calls installScheduledTasks($pluginID), which creates one row in scheduled_tasks per entry — next_run is initially set to NOW(), so the task runs on the next runner pass immediately.
Script folder
The registered scripts live in the plugin subfolder scheduledTasks/:
_public/extensions/core/backend/{plugin}/
├── bootstrap.php
└── scheduledTasks/
├── session_cleanup.php
├── generate_sitemap.php
└── publish_scheduled_pages.phpScript pattern
A scheduled-task script is a regular PHP file. The TaskRunner includes it via require — meaning the script runs in the context of a live DB connection; query(), fetch_assoc(), etc. are all available:
<?php
// _public/extensions/core/backend/{plugin}/scheduledTasks/cleanup_old_logs.php
// The TaskRunner has already set up the DB connection — no extra init needed
$cutoff = date('Y-m-d H:i:s', strtotime('-30 days'));
$cutoffEsc = real_escape_string($cutoff);
$q = query("DELETE FROM my_plugin_logs WHERE created_at < '$cutoffEsc'");
// Make the log visible in the Task Manager UI:
echo "Deleted " . mysqli_affected_rows($GLOBALS['connection']) . " old log entries\n";No output to the web — the script runs in CLI mode. Echo output lands in the CLI log (or in the Task Manager, when the runner caches the output).
Keep tasks short
The cron call scheduled-tasks --time-limit=540 has a 9-minute time limit (default for a 10-minute cron). When several tasks run together, they share the budget. For long work (sitemap for 10k pages, bulk re-indexing): split into several short tasks or move the work to the job queue (QueueWorker).
CLI runner
The TaskRunner is invoked through console/bin:
# Run every due task, 540s time limit max
php console/bin scheduled-tasks --time-limit=540
# Run a single task immediately (ignoring next_run)
php console/bin scheduled-tasks --run=42
# List every task
php console/bin list-tasksRecommended cron setup (crontab -e):
# Every 10 minutes: scheduled tasks
*/10 * * * * php /path/console/bin scheduled-tasks --time-limit=540
# Every minute: job queue
* * * * * php /path/console/bin process-queue --time-limit=55
# Every 30 seconds: webhooks
* * * * * php /path/console/bin process-webhooks --time-limit=25
* * * * * sleep 30 && php /path/console/bin process-webhooks --time-limit=25How the TaskRunner executes tasks
The TaskRunner::runDue() flow:
1. SELECT every task WHERE next_run <= NOW() AND active = 1 AND is_running = 0
2. For every task:
- SET is_running = 1 (stale check + parallel-run guard)
- require script
- SET last_run = NOW(), next_run = NOW() + interval_seconds, is_running = 0
3. If time runs out before the queue is finished: the rest continues on the next runis_running flag: prevents a task from running in parallel with itself. On crash, the flag stays stuck — in that case, clear it in the Task Manager or via UPDATE scheduled_tasks SET is_running = 0 WHERE id = X.
Task Manager UI
Under /admin/task-manager there are three tabs:
- Status — APCu info, last run per task, next run timestamp
- Queue — job queue (not scheduled tasks — this is the API-call queue via
QueueWorker) - Scheduled Tasks — list of every task with a "Run now" button and an
activetoggle
The sidebar shows a status lamp — green: the runner runs regularly; red: the last run was longer ago than expected.
Job queue vs. scheduled task
For one-off jobs (non-recurring), there's also the job queue:
require_once $_SERVER['DOCUMENT_ROOT'] . '/_core/system/cron/QueueWorker.php';
QueueWorker::createJob('api/pages', 'GET', ['view' => 'test'], 10, 3);
// Endpoint, HTTP method, params, priority (higher = more important), maxAttemptsThe QueueWorker (via the process-queue CLI) makes an internal cURL call against the site's own domain and stores the response/status in task_queue. Useful for fire-and-forget work from API endpoints that shouldn't block.
Details in the CLAUDE.md section "Cron/Task system" and in the php-backend-check skill.
Database table
CREATE TABLE IF NOT EXISTS scheduled_tasks (
id INT AUTO_INCREMENT PRIMARY KEY,
plugin_id INT NOT NULL DEFAULT 0, -- FK → plugins.id
name VARCHAR(255) NOT NULL,
script VARCHAR(255) NOT NULL, -- relative to plugin/scheduledTasks/
interval_seconds INT NOT NULL DEFAULT 3600,
description TEXT DEFAULT NULL,
last_run DATETIME DEFAULT NULL,
next_run DATETIME DEFAULT NULL,
is_running TINYINT(1) NOT NULL DEFAULT 0,
active TINYINT(1) NOT NULL DEFAULT 1,
language_short VARCHAR(5) NOT NULL DEFAULT '', -- currently unused, stays empty
base_id INT NOT NULL DEFAULT 0, -- currently unused, stays 0
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY plugin_id (plugin_id),
KEY active_next_run (active, next_run)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;Schema migration: _migrations/core/005_create_task_system.sql. language_short and base_id exist to stay consistent with other core tables, but the TaskRunner does not read them — scheduled tasks are system-wide, not language-specific.
Common issues
Script not placed under scheduledTasks/
The TaskRunner looks under _public/extensions/core/backend/{plugin}/scheduledTasks/{script}. If the file lives somewhere else (e.g. script/ or the plugin root), the runner raises a File not found error and marks the task as failed.
interval missing → default 3600s
If the interval field is absent from the JSON, the installer uses 3600 (1 hour) as the default. That's usually not what you want. Always set it explicitly.
JSON syntax error → task silently skipped
installScheduledTasks() uses json_decode(); on a null return (syntax error), the entry is silently skipped — no error message, no log. Lint the JSON before you commit, or temporarily add error_log(print_r($task, true)) after the json_decode to debug.
is_running stays at 1 after a crash
On a PHP fatal error or timeout, is_running = 0 is not reset. The task gets skipped on the next run as "already running". Manual fix: UPDATE scheduled_tasks SET is_running = 0 WHERE id = X.
update() doesn't refresh scheduled tasks
Changes to $this->scheduledTasks in a later plugin version only take effect on a fresh install. For existing installs: write a migration that updates or inserts the scheduled_tasks row.
See also
- Plugin Anatomy — the
$this->scheduledTasksproperty and thescheduledTasks/folder - Migrations —
_migrations/core/005_create_task_system.sql - Webhook Events — the
process-webhooksworker that uses the same CLI dispatcher - Task Manager UI:
/admin/task-manager