Scheduled Task Plugins in Joomla
|
This content was generated with the assistance of AI and should be verified against the official Joomla documentation before being relied on in production. This sub-section’s Joomla bibliography lists the reference material consulted while preparing these pages. |
A scheduled task plugin is a Joomla plugin of the group task that offers one or more routines to the scheduler
component com_scheduler. A site builder then creates tasks from those routines in System → Scheduled Tasks, gives
each a timetable and parameters, and Joomla runs them when the scheduler is started. Every core task (rotate logs,
delete action logs, global check-in, session garbage collection and so on) is such a plugin, and a third-party extension
adds its own work the same way instead of asking the administrator to maintain a hand-written cron script. This page
explains how to write one: the folder layout, the three events a task plugin subscribes to, the TaskPluginTrait that
does most of the work, the routine method contract, the parameter form, the exit codes, the locking behaviour, logging
and notifications, security, and a complete plugin for the Tallpine Hiking Club that synchronises trail closures from a
fictitious park-authority API.
It was written on 2026-10-30 against Joomla 6.2 (current, PHP 8.3 or newer), the Joomla Developer Manual and the 6.2
source tree. Joomla 5.4 is in security-only support, and Joomla 4.x and 3.x are end of life; where the task API behaved
differently the text says so. The Developer Manual has no page about task plugins, so the
reference for this topic is the source of com_scheduler and of the nine core plugins in plugins/task/*, which is what
the links below cite. Using the scheduler (choosing lazy, web or system cron, creating tasks, monitoring) is covered in
Scheduled Tasks in Joomla; plugin basics are in
Joomla plugin development and events; the container that builds the
plugin is explained in
Namespaces, autoloading and dependency injection.
PHP traits are not re-taught here; see Traits.
What a task plugin is, and who needs one
The scheduler separates three things:
-
a routine is a unit of work offered by a plugin, identified by a string such as
rotation.logsorplg_task_requests_task_get. It has a title and a description taken from language strings. -
a task is a row of the table
#__scheduler_tasks: a chosen routine, a title, execution rules, a priority, a state and the parameters entered in the form. -
a run is one execution of a task, which the scheduler brackets with a lock and records as an exit code, the time and counters.
The administrator creates tasks and decides what starts the scheduler. The developer writes routines. A task plugin has no screen of its own: com_scheduler asks every enabled task plugin which routines it offers, shows them in the New Task chooser, merges the plugin’s parameter form into the task edit form, and calls the plugin when a task is due. Write a task plugin when work has to happen on a timetable and must be visible to the administrator (last run, failures, manual run, e-mail on failure): nightly imports, cache warming, report generation, calls to external services, data cleaning. Do not write one for work that must happen immediately after a user action; that is an event listener in an ordinary plugin.
The moving parts
The relevant classes live in administrator/components/com_scheduler/src, namespace
Joomla\Component\Scheduler\Administrator:
| Class | Role |
|---|---|
|
Entry point used by the triggers: |
|
Reads and locks the task row ( |
|
One task during a run: acquires and releases the lock, dispatches |
|
The exit-code constants, described below. |
|
The collection of routines that plugins advertise, and one routine (identifier plus language prefix). |
|
The event object passed to |
|
The helper trait a plugin uses: advertising routines, loading the parameter form, calling the routine method, logging. |
Source: joomla-cms: TaskPluginTrait.php
The three events
A task plugin implements SubscriberInterface and returns the same three subscriptions as every core task plugin:
| Event | Trait method | When and why |
|---|---|---|
|
|
Dispatched by |
|
|
Dispatched by |
|
|
Dispatched by every form in Joomla. The trait reacts only to the form named |
Because the scheduler imports the whole task group before dispatching, the plugin does not need to be told which tasks
exist. A task whose routine is no longer offered (plugin disabled or uninstalled) is called orphaned: the scheduler
skips it, moves its next execution forward and fires onTaskRoutineNotFound, which the notification plugin reports by
mail.
Folder layout and manifest
The Tallpine plugin is part of the package pkg_tallpine. Its element is tallpine_trailsync, so the folder is
plugins/task/tallpine_trailsync after installation:
plg_task_tallpine_trailsync/
tallpine_trailsync.xml
services/
provider.php
src/
Extension/
TallpineTrailsync.php
forms/
syncForm.xml
language/
en-GB/
plg_task_tallpine_trailsync.ini
plg_task_tallpine_trailsync.sys.ini
de-DE/
plg_task_tallpine_trailsync.ini
plg_task_tallpine_trailsync.sys.ini
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="task" method="upgrade">
<name>plg_task_tallpine_trailsync</name>
<author>Tallpine Hiking Club</author>
<creationDate>2026-10</creationDate>
<copyright>(C) 2026 Tallpine Hiking Club</copyright>
<license>GNU General Public License version 2 or later</license>
<authorEmail>web@tallpine.example</authorEmail>
<authorUrl>https://www.tallpine.example</authorUrl>
<version>1.0.0</version>
<description>PLG_TASK_TALLPINE_TRAILSYNC_XML_DESCRIPTION</description>
<namespace path="src">Tallpine\Plugin\Task\TallpineTrailsync</namespace>
<files>
<folder plugin="tallpine_trailsync">services</folder>
<folder>src</folder>
<folder>forms</folder>
<folder>language</folder>
</files>
<languages>
<language tag="en-GB">language/en-GB/plg_task_tallpine_trailsync.ini</language>
<language tag="en-GB">language/en-GB/plg_task_tallpine_trailsync.sys.ini</language>
<language tag="de-DE">language/de-DE/plg_task_tallpine_trailsync.ini</language>
<language tag="de-DE">language/de-DE/plg_task_tallpine_trailsync.sys.ini</language>
</languages>
</extension>
Two details decide whether the plugin works. The forms folder must be listed in <files>, otherwise the parameter form
is never copied and the trait finds nothing to load. The plugin="tallpine_trailsync" attribute marks the folder that
holds provider.php and sets the element name. The full manifest vocabulary is in
Extension types, manifests and packaging.
The service provider
Task plugins use the same lazy service provider as every plugin since Joomla 4.0. The provider builds the plugin with
its configuration row and the services it needs, and hands it the application and the database. Do not rely on
$this->app or $this->db being filled in automatically: that auto-detection is deprecated and Joomla 7.0 removes it,
so use setApplication(), setDatabase() and getApplication() and getDatabase() from the Aware traits.
<?php
/**
* @package Tallpine.Plugin
* @subpackage Task.tallpine_trailsync
*/
\defined('_JEXEC') or die;
use Joomla\CMS\Extension\PluginInterface;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\Database\DatabaseInterface;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
use Joomla\Http\HttpFactory;
use Tallpine\Plugin\Task\TallpineTrailsync\Extension\TallpineTrailsync;
return new class () implements ServiceProviderInterface {
public function register(Container $container): void
{
$container->set(
PluginInterface::class,
$container->lazy(TallpineTrailsync::class, function (Container $container) {
$plugin = new TallpineTrailsync(
(array) PluginHelper::getPlugin('task', 'tallpine_trailsync'),
new HttpFactory()
);
$plugin->setApplication(Factory::getApplication());
$plugin->setDatabase($container->get(DatabaseInterface::class));
return $plugin;
})
);
}
};
The provider is lazy: the plugin object is created only when one of its events is first dispatched. That is why the trait
calls loadLanguage() itself in advertiseRoutines(), because the automatic language loading of the constructor may
not have run yet.
The plugin class and TASKS_MAP
The class extends CMSPlugin, implements SubscriberInterface and uses TaskPluginTrait. The trait expects a class
constant named TASKS_MAP, indexed by routine identifier. Each entry has three keys:
| Key | Meaning |
|---|---|
|
Prefix of two language constants, |
|
Name of the plugin method that implements the routine. It may be |
|
Optional. Base name of the file in |
Choose routine identifiers that cannot clash with another extension: core uses names such as delete.actionlogs, so
prefix yours with your vendor, as in tallpine.trailsync. The identifier is stored in the type column of every task
created from it, so renaming it later orphans those tasks.
<?php
namespace Tallpine\Plugin\Task\TallpineTrailsync\Extension;
use Joomla\CMS\Date\Date;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Component\Scheduler\Administrator\Event\ExecuteTaskEvent;
use Joomla\Component\Scheduler\Administrator\Task\Status;
use Joomla\Component\Scheduler\Administrator\Traits\TaskPluginTrait;
use Joomla\Database\DatabaseAwareTrait;
use Joomla\Database\ParameterType;
use Joomla\Event\SubscriberInterface;
use Joomla\Http\HttpFactory;
\defined('_JEXEC') or die;
final class TallpineTrailsync extends CMSPlugin implements SubscriberInterface
{
use DatabaseAwareTrait;
use TaskPluginTrait;
private const TASKS_MAP = [
'tallpine.trailsync' => [
'langConstPrefix' => 'PLG_TASK_TALLPINE_TRAILSYNC_SYNC',
'method' => 'syncClosures',
'form' => 'syncForm',
],
];
protected $autoloadLanguage = true;
public function __construct(array $config, private readonly HttpFactory $httpFactory)
{
parent::__construct($config);
}
public static function getSubscribedEvents(): array
{
return [
'onTaskOptionsList' => 'advertiseRoutines',
'onExecuteTask' => 'standardRoutineHandler',
'onContentPrepareForm' => 'enhanceTaskItemForm',
];
}
/**
* Routine tallpine.trailsync: pull trail closures changed since the last sync.
*/
private function syncClosures(ExecuteTaskEvent $event): int
{
$params = $event->getArgument('params');
$endpoint = (string) ($params->endpoint ?? '');
$timeout = max(1, (int) ($params->timeout ?? 30));
$limit = max(1, (int) ($params->batch ?? 100));
$token = (string) getenv('TALLPINE_PARK_API_TOKEN');
if ($token === '' || !str_starts_with($endpoint, 'https://')) {
$this->logTask('The endpoint must use https and TALLPINE_PARK_API_TOKEN must be set.', 'error');
return Status::KNOCKOUT;
}
$db = $this->getDatabase();
// The newest closure already stored is the cursor: the sync is stateless and idempotent.
$since = $db->setQuery(
$db->createQuery()
->select('MAX(' . $db->quoteName('closure_synced') . ')')
->from($db->quoteName('#__tallpine_trails'))
)->loadResult() ?: '1970-01-01 00:00:00';
$url = $endpoint . '?' . http_build_query(['updated_since' => $since, 'limit' => $limit]);
try {
$response = $this->httpFactory->getHttp()->get(
$url,
['Authorization' => 'Bearer ' . $token, 'Accept' => 'application/json'],
$timeout
);
} catch (\Throwable $e) {
$this->logTask('Park API request failed: ' . $e->getMessage(), 'error');
return Status::TIMEOUT;
}
if ($response->getStatusCode() !== 200) {
$this->logTask('Park API answered HTTP ' . $response->getStatusCode(), 'error');
return Status::KNOCKOUT;
}
try {
$closures = json_decode((string) $response->getBody(), true, 8, JSON_THROW_ON_ERROR)['closures'] ?? [];
} catch (\JsonException) {
$this->logTask('The park API did not return valid JSON.', 'error');
return Status::KNOCKOUT;
}
$parkRef = '';
$closed = 0;
$note = '';
$synced = '';
$query = $db->createQuery()
->update($db->quoteName('#__tallpine_trails'))
->set($db->quoteName('closed') . ' = :closed')
->set($db->quoteName('closure_note') . ' = :note')
->set($db->quoteName('closure_synced') . ' = :synced')
->where($db->quoteName('park_ref') . ' = :ref')
->bind(':closed', $closed, ParameterType::INTEGER)
->bind(':note', $note)
->bind(':synced', $synced)
->bind(':ref', $parkRef);
$db->setQuery($query);
$count = 0;
foreach ($closures as $closure) {
$parkRef = (string) ($closure['ref'] ?? '');
if ($parkRef === '') {
continue;
}
$closed = empty($closure['closed']) ? 0 : 1;
$note = mb_substr((string) ($closure['note'] ?? ''), 0, 255);
$synced = (new Date((string) ($closure['updated_at'] ?? 'now')))->toSql();
$db->execute();
$count++;
}
$this->logTask(\sprintf('%d trail closures synchronised.', $count));
$this->snapshot['output'] = \sprintf('Synchronised %d closures since %s', $count, $since);
// A full batch means more may be waiting: ask the scheduler to run this task again soon.
return $count >= $limit ? Status::WILL_RESUME : Status::OK;
}
}
The class is final like the core plugin classes. The columns closed, closure_note and closure_synced are not in the
first version of the #__tallpine_trails table. They are added by a SQL update file of the component, for example
sql/updates/mysql/1.3.0.sql with an ALTER TABLE statement per column (closed tinyint NOT NULL DEFAULT 0)
and similar statements for closure_note (varchar(255)) and closure_synced (datetime, nullable)
(Installer scripts and SQL updates). The component pages show
closed in the full table definition. The database code uses
DatabaseAwareTrait, a prepared statement bound once with variables that change in the loop, and no
Factory::getDbo(), which is deprecated and belongs to the legacy API; see
The Joomla database API for the query builder.
The routine method contract
standardRoutineHandler() does not call the mapped method blindly. It uses reflection and runs it only when the
signature is exactly one required parameter typed ExecuteTaskEvent and a declared return type int. A routine such as
private function sync($event) or private function sync(ExecuteTaskEvent $event): bool is rejected: the handler logs
"Incorrect routine method signature" to the task log and returns without filling the result snapshot. The scheduler then
sees the status RUNNING that it set before dispatching, which is not a success, so the task is recorded as failed with
last exit code 1 and times_failed goes up. The routine therefore:
-
receives the event, from which it reads
$event->getTaskId(),$event->getRoutineId()and$event->getArgument('params'), the decoded parameters as an object (the form fields in theparamsgroup) -
returns one of the
Statusconstants -
may write extra keys into
$this->snapshot;outputis shown by the administrator’s Run Task dialog, and any other key is merged into the run snapshot -
may call
$this->logTask($message, $priority), with prioritiesdebug,error,info,noticeandwarning, to write to the task’s log category
If the method is missing or TASKS_MAP is wrong, the handler logs "Incorrectly configured TASKS_MAP" and the status stays
NO_EXIT (-1), which the handler then replaces with INVALID_EXIT (-2) because -1 is not a value a routine may
return. Returning anything that is not an integer constant of Status is turned into INVALID_EXIT as well, so do not
invent your own codes.
Exit codes: the Status class
| Value | Constant | Meaning and what the scheduler does |
|---|---|---|
0 |
|
Success. Counters and next execution are updated; |
1 |
|
Set before the routine runs. Seen as a final status only when the routine never reported (see above). |
2 |
|
The lock could not be acquired; logged as "locked". |
3 |
|
The task did not run; logged as "unlocked". |
4 |
|
The task ran but its lock could not be released; set by the scheduler, never by a plugin. |
5 |
|
Failure. Use it for any error the routine detects itself; this is the code the core plugins return when a query fails. |
123 |
|
The routine did part of its work and wants another run. The task is counted neither as failed nor as completed: the
next execution is set one minute in the past so it is due again at the next scheduler pass, and |
124 |
|
The routine gave up waiting, for example on an HTTP call. Treated as a failure. |
125 |
|
No task found for the request. |
127 |
|
The task’s routine is not offered by any enabled plugin (orphaned task). |
-1 |
|
The routine never produced an exit code. |
-2 |
|
The routine returned something that is not a defined status. |
Only OK and WILL_RESUME count as success. For everything else the task’s times_failed is increased and
onTaskExecuteFailure is dispatched, which is what the notification plugin turns into an e-mail. The task list shows
the last exit code and the task filters can select executed, failed or "Task Will Resume".
Source: joomla-cms: Status.php
Exceptions
If the routine throws an \Exception, Task::run() catches it, logs the message, sets the status to KNOCKOUT, releases
the lock and dispatches the failure event; the exception is then rethrown to the scheduler, which swallows it for the
trigger. A \Error such as a TypeError is not an \Exception, so it escapes the task, ends the PHP process and leaves
the task locked until the timeout. Catch \Throwable around risky code, as the sync routine does around the HTTP call,
and return a status instead of letting the process die.
The parameter form
The form file adds fields to the task edit screen. Fields must sit in <fields name="params"> so that their values are
stored in the task’s params JSON and arrive in $event->getArgument('params').
<?xml version="1.0" encoding="UTF-8"?>
<form>
<fields name="params">
<fieldset name="task_params">
<field
name="endpoint"
type="url"
label="PLG_TASK_TALLPINE_TRAILSYNC_LABEL_ENDPOINT"
description="PLG_TASK_TALLPINE_TRAILSYNC_DESC_ENDPOINT"
required="true"
validate="url"
filter="url"
schemes="https"
hint="https://api.park-authority.example/v1/closures"
/>
<field
name="timeout"
type="number"
label="PLG_TASK_TALLPINE_TRAILSYNC_LABEL_TIMEOUT"
default="30"
min="1"
max="120"
step="1"
filter="int"
validate="number"
/>
<field
name="batch"
type="number"
label="PLG_TASK_TALLPINE_TRAILSYNC_LABEL_BATCH"
default="100"
min="10"
max="500"
step="10"
filter="int"
validate="number"
/>
</fieldset>
</fields>
</form>
Everything in Forms and form fields applies: filter, validate, showon,
custom field types. The scheduler adds its own fields around yours (title, state, note, execution rules, priority with
the values low -1, normal 0 and high 1, and the logging fieldset with Individual Log). The routine identifier is read
from the task being edited, from its type, or from the posted jform[type], which is why the right fieldset appears as
soon as the administrator picks a routine.
Never ask for a secret in this form. Task parameters are stored as plain JSON in the database and shown to every user who
can edit the task. The sync reads its bearer token from the environment variable TALLPINE_PARK_API_TOKEN; the
environment of the web server and of the cron job must both define it (see the pitfalls below).
Language files
Two constants per routine are required, plus those used by the form and the manifest:
; plg_task_tallpine_trailsync.ini
PLG_TASK_TALLPINE_TRAILSYNC_SYNC_TITLE="Synchronise trail closures"
PLG_TASK_TALLPINE_TRAILSYNC_SYNC_DESC="Pulls trail closures changed since the last run from the park authority and updates the trail list."
PLG_TASK_TALLPINE_TRAILSYNC_LABEL_ENDPOINT="API endpoint"
PLG_TASK_TALLPINE_TRAILSYNC_DESC_ENDPOINT="HTTPS address of the closures feed. The access token is read from the server environment."
PLG_TASK_TALLPINE_TRAILSYNC_LABEL_TIMEOUT="Request timeout (seconds)"
PLG_TASK_TALLPINE_TRAILSYNC_LABEL_BATCH="Closures per run"
; plg_task_tallpine_trailsync.sys.ini
PLG_TASK_TALLPINE_TRAILSYNC="Task - Tallpine trail closures"
PLG_TASK_TALLPINE_TRAILSYNC_XML_DESCRIPTION="Scheduled routine that synchronises trail closures from the park authority."
The .sys.ini file is loaded on the plugin list; the plain .ini file is loaded by $autoloadLanguage and by the
trait when the chooser is built. If the chooser shows PLG_TASK_..._SYNC_TITLE as raw text, the .ini file is missing,
misnamed or not declared in <languages>. See
Language files and internationalisation.
Locking and concurrency
The scheduler protects a task against being run twice, with a pseudo-lock: the column locked of the task row holds the
time the run started, and NULL means free. The mechanics in TaskModel::getTask() and Task are:
-
The model locks the whole
#__scheduler_taskstable for the duration of the pick (lockTable()andunlockTables()), so two requests cannot choose the same row. -
Unless concurrency is allowed, it first counts tasks of state enabled whose
lockedtime is newer than the Task Timeout option ofcom_scheduler(default 300 seconds). If one exists, nothing is returned: only one task runs at a time, site-wide. -
It selects the due task with the highest priority and, among equals, the earliest
next_execution, and setslockedto now. Tasks markedcli_exclusiveare skipped unless the caller is the command line; running by ID bypasses the "due" check but still needs the lock. -
After the routine returns,
Task::run()writes the exit code, the last and next execution times and the counters, and setslockedback toNULLin a single update. If that update changes no row, the status becomesNO_RELEASE.
The consequences for plugin authors:
-
The timeout is not a kill switch. The scheduler calls
set_time_limit(0)and PHP is never interrupted. After 300 s another trigger considers the lock stale and may start a different task, or acquire this one, while the first run is still working. A routine that can outlast the timeout must be idempotent and should work in batches and returnWILL_RESUME, as the Tallpine sync does, or the administrator must raise the Task Timeout option. -
A crashed process leaves the lock set. After a fatal error, a killed container or a PHP memory exhaustion the row stays locked. The list screen shows "running since", and the Unlock toolbar button (guarded by the edit-state permission) clears the lock; otherwise the lock expires with the timeout.
-
One task at a time means a slow task delays every other task. Keep routines short or run them from system cron with
scheduler:run --id. -
Tasks are checked out for editing like other items; a task being edited is not blocked from running.
Behaviour of the lock query has changed during 4.4 and 5.x (a fix in 4.4.9 added the running-task count, and 5.2 added the
cli_exclusive condition to it), so test on the exact release you target.
Logging, output and notifications
logTask() writes to the log category task<ID>. The scheduler registers a file logger for it, so every run appends to
joomla_scheduler.php in the site’s log folder, with the date, time, priority and message; enabling Individual Log on a
task also writes its category to a file of its own (by default task_<ID>.log.php). Messages are prefixed with the
translation of COM_SCHEDULER_ROUTINE_LOG_PREFIX. Log what happened and counts, never tokens or full response bodies.
The system plugin System - Task Notification (plg_system_tasknotification) subscribes to the exit events and, based on
the Notifications fieldset it injects into every task form (success, failure, fatal failure and orphan mails), sends the
mail templates of the scheduler to the chosen user groups. A task plugin does nothing to take part; see
Mail templates and e-mail settings. For the Tallpine site, turn
failure mails on for the Trail Warden group and success mails off, otherwise the warden receives a message every run.
The events onTaskExecuteSuccess, onTaskExecuteFailure, onTaskRoutineWillResume, onTaskRoutineNotFound and
onTaskRecoverFailure are also available to your own plugins, for instance to purge the page cache after a successful
sync (see Caching and performance).
Creating, running and debugging the task
-
Install
pkg_tallpine(or the plugin alone) and enable Task - Tallpine trail closures in System → Manage → Plugins. A disabled plugin does not advertise its routine, and its tasks are treated as orphaned. -
Set
TALLPINE_PARK_API_TOKENfor the web server and for the user that runs the scheduler (see the Docker Compose fragment below). -
In System → Scheduled Tasks → New, choose Synchronise trail closures, set the endpoint, an interval of 30 minutes and the notification options, and save.
-
Test it with the Run Task button of the task list (available to users with the Run Manually permission), or from the command line:
cd /var/www/html
php cli/joomla.php scheduler:list
php cli/joomla.php scheduler:run --id=7
tail -n 20 administrator/logs/joomla_scheduler.php
For a local site, the Compose file below passes the token to the PHP container, and a second container could run the
scheduler on a loop; the token value is a placeholder and real values belong in an .env file kept out of version
control.
services:
joomla:
image: joomla:6.2-php8.3-apache
environment:
JOOMLA_DB_HOST: db
JOOMLA_DB_USER: tallpine
JOOMLA_DB_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env}
JOOMLA_DB_NAME: tallpine
TALLPINE_PARK_API_TOKEN: ${TALLPINE_PARK_API_TOKEN:-CHANGE_ME}
volumes:
- ./site:/var/www/html
depends_on: [db]
scheduler:
image: joomla:6.2-php8.3-apache
entrypoint: ["sh", "-c", "while true; do php /var/www/html/cli/joomla.php scheduler:run --all; sleep 60; done"]
environment:
JOOMLA_DB_HOST: db
TALLPINE_PARK_API_TOKEN: ${TALLPINE_PARK_API_TOKEN:-CHANGE_ME}
volumes:
- ./site:/var/www/html
depends_on: [db]
db:
image: mariadb:11.4
environment:
MARIADB_DATABASE: tallpine
MARIADB_USER: tallpine
MARIADB_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env}
MARIADB_RANDOM_ROOT_PASSWORD: "1"
Typical symptoms and their causes:
| Symptom | Likely cause |
|---|---|
Routine is missing from the New Task chooser |
Plugin disabled; |
Parameter fields do not appear |
|
Task always ends with exit code 1 |
Routine signature not |
Task ends with -2 |
The routine returned |
Works from "Run Task" but not from cron |
Different environment variables, PHP binary, file owner or |
Task shows "running since" for ever |
The previous process died; use Unlock, or wait for the timeout. |
Task never runs on a quiet site |
Only the lazy scheduler is active and nobody visits; use web cron or the CLI. |
Security
-
Who can create tasks. Creating and editing tasks needs the
core.createandcore.editpermissions ofcom_scheduler, and running one manually needscore.testrun. Treat every task parameter as input from an administrator, validate it in the form XML and again in the routine, and remember that a task with a URL parameter is a way to make the server send requests. Restrict endpoints tohttpsand, if the set of hosts is fixed, to an allow-list; the core Requests routine accepts anyhttporhttpsaddress, which is acceptable for a trusted administrator but not for a plugin whose parameters other roles can edit. -
Secrets come from the environment or from a vault, not from the form, the task note or the log. The core Requests routine offers a free-text authorisation field that is stored in the task parameters; avoid copying that pattern.
-
The lazy and web-cron endpoints are public. They only run tasks that are already due, but a routine therefore must never assume a logged-in user or an administrator session.
$this->getApplication()may be the site, the administrator or the console, and in the CLI there is no user. -
Untrusted data. A remote feed is untrusted: cap the response size and depth (
json_decodewith a depth limit, as above), truncate text fields to the column size, bind every value and never concatenate it into SQL. -
Files. Do not write remote responses to a web-accessible folder. The core Requests routine writes the body of each response to
tmp/task_<ID>_response.html, and a comment in its source says the handling is a demonstration that is to be reworked; do not model new code on it. -
Least privilege on the command line. Run
scheduler:runas the website user, never asroot, so created files stay writable by the web server. See Security in extensions.
Testing
The routine is easiest to test when the pure parts (building the URL, mapping a closure to column values, choosing
between OK and WILL_RESUME) live in small methods or a separate class that does not need Joomla. Unit-test that class
with PHPUnit and a fake HttpFactory response, and cover the full run with a system test that creates a task, runs
scheduler:run --id and asserts on last_exit_code in the database. Joomla’s own system tests use Cypress, not
Codeception. See Testing extensions.
Version notes
-
4.1 introduced
com_scheduler, thetaskplugin group and the first task plugins; the core set has been stable since (check files, delete action logs, global check-in, privacy consent, requests, rotate logs, session GC, site status, update notification). -
4.4.9 and 5.2 changed the lock query (running-task count and
cli_exclusive). -
5.0 brought concrete event classes for most core events. The scheduler’s
ExecuteTaskEventis a dedicated class in 6.2, so a task plugin reads its data through getters instead of positional arguments. -
6.0 made the legacy
J*aliases and other removed APIs available only through the backward-compatibility plugin; useDatabaseAwareTraitas above. -
7.0 will remove the automatic
$appand$dbproperties of plugins. The provider in this page already injects both explicitly.
Older books and tutorials need correcting here. Developing Extensions for Joomla! 5 (2023) does not cover task plugins at
all, so its plugin chapters give no help for writing a routine. Joomla! 4 Masterclass (2023) shows Scheduled Tasks from
the administrator’s side for Joomla 4.1, presents the lazy scheduler as the way to run tasks, and links to
docs.joomla.org pages of the 4.x series; Joomla 4 is end of life, system or web cron is the recommended trigger on a
production site, and the current documentation is the User Guide at guide.joomla.org. Tutorials that show a task plugin
written as a plain CMSPlugin with $this->db and a hand-written onExecuteTask handler predate the trait and the
Aware traits; they work only with the backward-compatibility layer and should be rewritten as shown above.