Skip to content

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():

php
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:

FieldRequiredPurpose
nameyesUI label in the Task Manager
scriptyesFile name relative to {plugin}/scheduledTasks/
intervalyesInterval in seconds (300 = 5 min, 3600 = 1 h, 86400 = 24 h). The alias interval_seconds is also accepted
descriptionnoText in the Task Manager (tooltip/info)

Real example from the scheduledTasks plugin (_public/extensions/core/backend/scheduledTasks/bootstrap.php):

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.php

Script 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
<?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:

bash
# 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-tasks

Recommended cron setup (crontab -e):

cron
# 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=25

How 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 run

is_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 active toggle

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:

php
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), maxAttempts

The 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

sql
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->scheduledTasks property and the scheduledTasks/ folder
  • Migrations_migrations/core/005_create_task_system.sql
  • Webhook Events — the process-webhooks worker that uses the same CLI dispatcher
  • Task Manager UI: /admin/task-manager