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.logs or plg_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

sequenceDiagram participant T as Trigger: lazy, web cron or CLI participant S as Scheduler participant M as TaskModel participant P as Task plugin participant N as System plugin tasknotification T->>S: run next due task S->>M: getTask(): lock the table, pick by priority then next_execution M-->>S: task row, locked column set S->>P: dispatch onExecuteTask with ExecuteTaskEvent P->>P: standardRoutineHandler calls the mapped method P-->>S: result snapshot with status, duration and output S->>M: release lock, save exit code and next_execution S->>N: dispatch onTaskExecuteSuccess or onTaskExecuteFailure

The relevant classes live in administrator/components/com_scheduler/src, namespace Joomla\Component\Scheduler\Administrator:

Class Role

Scheduler\Scheduler

Entry point used by the triggers: runTask(), getTask(), and the access check isAuthorizedToRun().

Model\TaskModel

Reads and locks the task row (getTask()), and unlocks tasks from the list screen.

Task\Task

One task during a run: acquires and releases the lock, dispatches onExecuteTask, updates the counters and the next execution time, and dispatches the exit event.

Task\Status

The exit-code constants, described below.

Task\TaskOptions and Task\TaskOption

The collection of routines that plugins advertise, and one routine (identifier plus language prefix).

Event\ExecuteTaskEvent

The event object passed to onExecuteTask; it carries the task, the routine identifier and the decoded parameters, and receives the result snapshot.

Traits\TaskPluginTrait

The helper trait a plugin uses: advertising routines, loading the parameter form, calling the routine method, logging.

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

onTaskOptionsList

advertiseRoutines()

Dispatched by SchedulerHelper::getTaskOptions() whenever the scheduler needs the list of routines (the chooser, and before locking a task). The trait adds one TaskOption per TASKS_MAP entry to the shared TaskOptions subject. The list is cached for the request and sorted by title.

onExecuteTask

standardRoutineHandler()

Dispatched by Task::run() with an ExecuteTaskEvent. The plugin ignores routine identifiers that are not in its map, runs its own routine, and stores the result snapshot on the event. Setting a non-empty snapshot stops propagation, so no other task plugin sees that execution.

onContentPrepareForm

enhanceTaskItemForm()

Dispatched by every form in Joomla. The trait reacts only to the form named com_scheduler.task, finds the routine identifier of the task being edited and, if the routine maps to a form file, loads forms/<name>.xml from the plugin folder into the task form. This is how a routine gets its own parameter fields.

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

langConstPrefix

Prefix of two language constants, <prefix>_TITLE and <prefix>_DESC, which give the routine its name and description in the chooser. An entry without this key is silently left out of the chooser.

method

Name of the plugin method that implements the routine. It may be private or protected.

form

Optional. Base name of the file in forms/ (without .xml) that holds the routine’s parameter fields. Omit it for a routine without parameters, as the core Site Status routine does.

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 the params group)

  • returns one of the Status constants

  • may write extra keys into $this->snapshot; output is 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 priorities debug, error, info, notice and warning, 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

OK

Success. Counters and next execution are updated; onTaskExecuteSuccess is dispatched.

1

RUNNING

Set before the routine runs. Seen as a final status only when the routine never reported (see above).

2

NO_LOCK

The lock could not be acquired; logged as "locked".

3

NO_RUN

The task did not run; logged as "unlocked".

4

NO_RELEASE

The task ran but its lock could not be released; set by the scheduler, never by a plugin.

5

KNOCKOUT

Failure. Use it for any error the routine detects itself; this is the code the core plugins return when a query fails.

123

WILL_RESUME

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 onTaskRoutineWillResume is dispatched. No core plugin uses it.

124

TIMEOUT

The routine gave up waiting, for example on an HTTP call. Treated as a failure.

125

NO_TASK

No task found for the request.

127

NO_ROUTINE

The task’s routine is not offered by any enabled plugin (orphaned task).

-1

NO_EXIT

The routine never produced an exit code.

-2

INVALID_EXIT

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

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:

  1. The model locks the whole #__scheduler_tasks table for the duration of the pick (lockTable() and unlockTables()), so two requests cannot choose the same row.

  2. Unless concurrency is allowed, it first counts tasks of state enabled whose locked time is newer than the Task Timeout option of com_scheduler (default 300 seconds). If one exists, nothing is returned: only one task runs at a time, site-wide.

  3. It selects the due task with the highest priority and, among equals, the earliest next_execution, and sets locked to now. Tasks marked cli_exclusive are skipped unless the caller is the command line; running by ID bypasses the "due" check but still needs the lock.

  4. After the routine returns, Task::run() writes the exit code, the last and next execution times and the counters, and sets locked back to NULL in a single update. If that update changes no row, the status becomes NO_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 return WILL_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

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

  2. Set TALLPINE_PARK_API_TOKEN for the web server and for the user that runs the scheduler (see the Docker Compose fragment below).

  3. In System → Scheduled Tasks → New, choose Synchronise trail closures, set the endpoint, an interval of 30 minutes and the notification options, and save.

  4. 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; langConstPrefix key missing; onTaskOptionsList not subscribed; class or namespace does not match <namespace path="src">; plugin folder not marked with plugin="...".

Parameter fields do not appear

form key missing or misspelled; forms folder missing from <files>; fields not inside <fields name="params">; XML not well formed.

Task always ends with exit code 1

Routine signature not (ExecuteTaskEvent $event): int; check the scheduler log for "Incorrect routine method signature".

Task ends with -2

The routine returned true, null or a number that is not a Status constant.

Works from "Run Task" but not from cron

Different environment variables, PHP binary, file owner or php.ini between web and CLI; the token or a writable path is missing in one of them.

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.create and core.edit permissions of com_scheduler, and running one manually needs core.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 to https and, if the set of hosts is fixed, to an allow-list; the core Requests routine accepts any http or https address, 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_decode with 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:run as the website user, never as root, 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, the task plugin 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 ExecuteTaskEvent is 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; use DatabaseAwareTrait as above.

  • 7.0 will remove the automatic $app and $db properties 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.