Developer documentation for the scheduled task system.
The scheduled tasks system provides a general-purpose framework for running tasks on a schedule. Tasks are PHP classes paired with JSON config files. A single cron entry runs every 15 minutes and executes any tasks that are due.
Cron (every 15 min)
→ utils/process_scheduled_tasks.php
→ Load active ScheduledTask records from DB
→ For each task where is_due():
1. Resolve and instantiate the task class
2. Call run($config) with the task's sct_task_config
3. Update last_run_time and last_run_statusEach task consists of two files sharing the same base name:
tasks/
WeeklyEventsDigest.php ← PHP class implementing ScheduledTaskInterface
WeeklyEventsDigest.json ← Metadata and default configuration
plugins/bookings/tasks/
BookingReminder.php
BookingReminder.jsonPlace in /tasks/ (core) or /plugins/{plugin}/tasks/ (plugin).
{
"name": "My Task Name",
"description": "What this task does",
"default_frequency": "daily",
"default_day_of_week": 1,
"default_time": "09:00:00",
"config_fields": {
"some_setting": {"type": "text", "label": "Some Setting", "required": true}
}
}Fields:
name — Display name in admindescription — Explains what the task doesdefault_frequency — Default frequency: every_run, hourly, daily, weekly (defaults to daily)default_day_of_week — Default schedule day (0=Sunday–6=Saturday, only used for weekly)default_time — Default time of day (HH:MM:SS, only used for daily and weekly). Omit the key entirely for other frequencies — the value is written verbatim to a time column, so an empty string fails activationconfig_fields — Task-specific parameters rendered in admin formtext — Text inputnumber — Numeric inputboolean — Checkboxmailing_list — Mailing list dropdown (populated from database)<?php
require_once(PathHelper::getIncludePath('includes/ScheduledTaskInterface.php'));
class MyTaskName implements ScheduledTaskInterface {
public function run(array $config) {
// $config contains values from sct_task_config (set via admin form)
// Do work here...
// Return an array with status and human-readable message
// Status meanings:
// 'success' — Ran and completed (with or without work to do)
// 'skipped' — Could not run (misconfigured, missing prerequisite)
// 'error' — Attempted to run but failed
return array('status' => 'success', 'message' => 'Processed 5 items');
}
}Sending email from a task. For digest/notification tasks, send through EmailSender — the
one-call helper is EmailSender::sendTemplate($templateName, $to, $values), or build an
EmailMessage::fromTemplate(...) and call $sender->send($message) for more control. See the
Email System → Development Patterns for the full API; do
not hand-roll mail() or PHPMailer calls. tasks/WeeklyEventsDigest.php is the worked example.
A task can ask the runner to flip its sct_is_active to false after the
current run by adding 'deactivate' => true to the result array:
return array(
'status' => 'success',
'message' => 'No more work to do.',
'deactivate' => true,
);This is the right pattern for self-limiting tasks (e.g. CloudOffloadRun
deactivates itself once no store is offloading or draining). The runner reads the flag, sets
sct_is_active = false on the task row, and saves — so the row is not
re-evaluated on subsequent ticks until something explicitly reactivates
it.
Setting sct_is_active = false from inside the task with a separate
save() does not work: the runner holds an in-memory snapshot of
the row from before the call to run(), and its post-run save would
overwrite the deactivation. Use the deactivate flag.
Tasks can implement the ScheduledTaskDryRunnable interface to support preview/dry run from the admin UI. This is especially useful for email tasks where you want to see what would be sent without actually sending.
class MyTaskName implements ScheduledTaskInterface, ScheduledTaskDryRunnable {
public function run(array $config) {
// ... normal execution with side effects
}
public function dryRun(array $config) {
// Perform all read/computation logic but skip side effects
// (no sending emails, no deleting records, no API calls)
return array(
'status' => 'success',
'message' => 'Would process 5 items',
'html' => $preview_html, // Optional: rendered in admin UI
);
}
}Return keys:
status (string, required) — Same as run(): success, skipped, errormessage (string, required) — Summary of what would happen (e.g., "Would send 5 events to 42 recipients")html (string, optional) — HTML preview displayed inline on the admin page (e.g., the email body)Navigate to Admin > System > Scheduled Tasks. The task appears under "Available Tasks". Click Activate to create the database row and enable scheduling.
Table: sct_scheduled_tasks
| Column | Type | Description |
|---|---|---|
sct_scheduled_task_id | int8 (serial) | Primary key |
sct_name | varchar(255) | Display name |
sct_task_class | varchar(255) | PHP class name |
sct_is_active | bool | Whether task runs on schedule |
sct_frequency | varchar(20) | every_run, hourly, daily, weekly |
sct_schedule_day_of_week | int4 | 0=Sun–6=Sat (weekly only) |
sct_schedule_time | time | Time of day in site timezone (daily/weekly only) |
sct_task_config | jsonb | Task-specific configuration |
sct_last_run_time | timestamp | When task last ran |
sct_last_run_status | varchar(50) | success/error/skipped/orphaned |
sct_last_run_message | varchar(500) | Human-readable result detail |
sct_create_time | timestamp | Row creation time |
sct_delete_time | timestamp | Soft delete time |
ScheduledTask (single), MultiScheduledTask (collection)MultiScheduledTask filter options: active (bool), deleted (bool), task_class (string)
ScheduledTask::is_due()Behavior depends on sct_frequency:
every_run — Always due (runs every cron invocation, ~15 min)hourly — Due if not already run in the current clock hourdaily — Due if past sct_schedule_time today (site timezone) and not already run todayweekly — Due if correct sct_schedule_day_of_week, past sct_schedule_time, and not already run todaydefault_timezone setting).ScheduledTask::resolve_task_file()Searches for the PHP class file:
/tasks/{class_name}.php/plugins/*/tasks/{class_name}.phpScheduledTask::get_task_config()Returns sct_task_config as an associative array.
File: utils/process_scheduled_tasks.php
scheduled_tasks_last_cron_run setting (heartbeat)Deploys ship and remove code; they never touch the sct_scheduled_tasks
table. So when a task is removed or consolidated in a release, its
activation row is intentionally left behind (orphan-by-design) — a deploy
will not silently delete an operator's configured schedule and config
based on a file no longer being present (which could also be a partial
deploy, a rename, or an unsynced plugin).
When the runner hits such a row, resolve_task_file() returns null and it
records a distinct orphaned status with a "remove this task in admin,
or restore the file" message. An orphan *still counts toward the run's
error tally, so the cron-health summary stays honest — it is a real
"this needs attention" condition, just a remove-or-restore cleanup item
rather than a task that ran and failed.
The admin Active Tasks list flags orphans with an Orphaned badge, detected live (by resolving the file on page load) so it reflects the current deploy rather than the last stored status. Cleanup is a manual, per-site operator action: Deactivate the task (soft-deletes the row). Consolidation therefore has two operator steps on each site — activate the new task, and remove the obsolete ones.
Each task's run() is wrapped in pg_try_advisory_lock(hashtext(sct_name)),
so a long-running task cannot be re-entered by the next cron tick. If the
lock cannot be acquired the task is skipped with skipped: already running
and the runner moves on to the next task. The lock auto-releases when the
PHP connection closes, so a crashed process self-recovers on the next tick.
This is transparent to task implementations — no run() code needs to
know about the lock — but it means tasks that legitimately want to run
in parallel across ticks would be serialized. In practice the cron tick
interval (15 min) is long compared to almost every task's runtime, so
the serialization is rarely visible.
Standard server — Add to the www-data user's crontab (sudo crontab -e -u www-data):
*/15 * * * * php /var/www/html/{sitename}/public_html/utils/process_scheduled_tasks.php >> /var/www/html/{sitename}/logs/cron_scheduled_tasks.log 2>&1Docker container — Ensure cron is installed and running, then create /etc/cron.d/scheduled-tasks:
*/15 * * * * www-data php /var/www/html/{sitename}/public_html/utils/process_scheduled_tasks.php >> /var/www/html/{sitename}/logs/cron_scheduled_tasks.log 2>&1Note: Docker containers may not have cron installed by default. Install with apt-get install -y cron and start the daemon with cron. The cron daemon must also be started after container restart (add to entrypoint if needed).
New installs get the crontab entry automatically via _site_init.sh. Existing sites see a warning on the admin page with setup instructions if cron hasn't run in 30+ minutes.
File: adm/admin_scheduled_tasks.php
Logic: adm/logic/admin_scheduled_tasks_logic.php
Menu: System > Scheduled Tasks (permission level 10)
Sections:
Tasks in /plugins/{plugin}/tasks/ are discovered automatically alongside core tasks. Each needs both a .json and .php file.
Each plugin task record stores the owning plugin name in sct_plugin_name. This field is populated automatically when a task is activated via the admin UI for a task discovered in a plugin's /tasks/ directory.
Plugin-owned tasks follow the plugin lifecycle:
sct_plugin_name are suspended (sct_is_active = false). They will not run until the plugin is reactivated.sct_is_active = true).sct_plugin_name are permanently deleted (not just suspended).The Mailbox plugin's PollImapAccounts task (every_run) illustrates a
two-level cadence. The task frequency is a floor: it fires every cron pass but
does no per-mailbox work unless an account is due. Each IMAP account carries its
own iia_poll_interval_seconds (default 300), and the task only polls accounts
whose interval has elapsed — so the per-account interval is the real cadence,
and the task can run frequently without hammering every mailbox. Each account is
claimed with an atomic conditional UPDATE (stamping iia_last_poll_time on
pickup) so two overlapping runs can't race the same account's UID cursor. Failures
are per-account and non-fatal — one unreachable mailbox is recorded in that
account's status and never fails the run. See
Receiving by IMAP poll.
| File | Purpose |
|---|---|
data/scheduled_tasks_class.php | Data model classes |
includes/ScheduledTaskInterface.php | Task interface |
utils/process_scheduled_tasks.php | Cron runner |
adm/admin_scheduled_tasks.php | Admin page view |
adm/logic/admin_scheduled_tasks_logic.php | Admin page logic |
tasks/WeeklyEventsDigest.php | Example email digest task |
tasks/WeeklyEventsDigest.json | Example email digest config |
tasks/PurgeOldErrors.php | Example cleanup task |
tasks/PurgeOldErrors.json | Example cleanup config |
tasks/SyncPaypalSubscriptions.php | PayPal subscription backstop |
tasks/ReconcileStripeSubscriptions.php | Stripe subscription backstop (bulk-list) |
migrations/migration_scheduled_tasks_init.php | Setup migration |
SyncPaypalSubscriptions and ReconcileStripeSubscriptions are sibling backstops
to their respective webhooks: webhooks are the authoritative real-time path for
subscription state, and these daily tasks catch anything a webhook missed
(cancellations, period rollovers, status changes).
ReconcileStripeSubscriptions loads the global working set
(MultiOrderItem(['is_active_subscription' => true])), then pages Stripe's
subscription list endpoint* (`get_subscriptions(['status' => 'all', 'limit' =>
100])`, up to 100 per call, stopping once every wanted id is found) and applies
each via StripeHelper::apply_subscription_to_order_item() — one bulk fetch
rather than a per-item round-trip. It writes a single EventLog summary row per
run and implements ScheduledTaskDryRunnable for a no-write preview of pending
changes.