Scheduled Tasks in Joomla: writing your own task plugin instead of a cron script
Write your own Joomla Scheduled Tasks plugin instead of a cron script: a full example, the form and language strings, and why a task gets stuck.
Every site collects small chores. Export the new orders to a CSV every night, clear a folder of uploaded files once a week, ping an external API every hour. The old answer was a standalone PHP script plus a cron line on the server, often with a copy of the database password inside it. Since Joomla 4.1 there is a better place for this: the Scheduled Tasks component, under System > Scheduled Tasks.
Core ships a handful of task types: Global Check-in, Delete Action Logs, Session Data Purge, Rotate Logs, Site Status and a plain GET request to a URL. The useful part is that you can add your own type with a small plugin. Your code then runs inside Joomla, with the database, the configuration and the logging already set up, and a site administrator can schedule it, pause it and read its history without touching the server.
What a task plugin is
A task plugin lives in the task plugin group. It tells the scheduler which routines it offers, and the scheduler calls the right method when a task of that type is due. Most of the plumbing comes from one trait in the Scheduler component, TaskPluginTrait. You fill in a map of routines and write one method per routine.
The example below adds a task type "Delete old files" that removes files older than a set number of days from a folder you choose. It is written for Joomla 5. The folder layout is the usual one for a modern plugin:
plg_task_cleanfolder/
cleanfolder.xml
services/provider.php
src/Extension/Cleanfolder.php
forms/deleteForm.xml
language/en-GB/plg_task_cleanfolder.ini
language/en-GB/plg_task_cleanfolder.sys.ini
The manifest and the service provider
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="task" method="upgrade">
<name>plg_task_cleanfolder</name>
<version>1.0.0</version>
<description>PLG_TASK_CLEANFOLDER_XML_DESCRIPTION</description>
<namespace path="src">Doctorjoomla\Plugin\Task\Cleanfolder</namespace>
<files>
<folder plugin="cleanfolder">services</folder>
<folder>src</folder>
<folder>forms</folder>
<folder>language</folder>
</files>
</extension>
The service provider is the same boilerplate as for any Joomla 5 plugin:
<?php
defined('_JEXEC') or die;
use Doctorjoomla\Plugin\Task\Cleanfolder\Extension\Cleanfolder;
use Joomla\CMS\Extension\PluginInterface;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
use Joomla\Event\DispatcherInterface;
return new class () implements ServiceProviderInterface {
public function register(Container $container): void
{
$container->set(PluginInterface::class, function (Container $container) {
$plugin = new Cleanfolder(
$container->get(DispatcherInterface::class),
(array) PluginHelper::getPlugin('task', 'cleanfolder')
);
$plugin->setApplication(Factory::getApplication());
return $plugin;
});
}
};
The plugin class
<?php
namespace Doctorjoomla\Plugin\Task\Cleanfolder\Extension;
defined('_JEXEC') or die;
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\Event\SubscriberInterface;
final class Cleanfolder extends CMSPlugin implements SubscriberInterface
{
use TaskPluginTrait;
protected const TASKS_MAP = [
'cleanfolder.delete' => [
'langConstPrefix' => 'PLG_TASK_CLEANFOLDER_DELETE',
'method' => 'deleteOldFiles',
'form' => 'deleteForm',
],
];
protected $autoloadLanguage = true;
public static function getSubscribedEvents(): array
{
return [
'onTaskOptionsList' => 'advertiseRoutines',
'onExecuteTask' => 'standardRoutineHandler',
'onContentPrepareForm' => 'enhanceTaskItemForm',
];
}
private function deleteOldFiles(ExecuteTaskEvent $event): int
{
$params = $event->getArgument('params');
$folder = realpath(JPATH_ROOT . '/' . trim((string) ($params->folder ?? ''), '/'));
$days = max(1, (int) ($params->days ?? 30));
if ($folder === false || !str_starts_with($folder, JPATH_ROOT . '/')) {
$this->logTask('Folder missing or outside the site root', 'error');
return Status::KNOCKOUT;
}
$limit = time() - $days * 86400;
$deleted = 0;
foreach (new \DirectoryIterator($folder) as $file) {
if ($file->isFile() && $file->getFilename() !== 'index.html'
&& $file->getMTime() < $limit && @unlink($file->getPathname())) {
$deleted++;
}
}
$this->logTask(sprintf('Deleted %d file(s) older than %d days from %s', $deleted, $days, $folder));
return Status::OK;
}
}
The three events are all handled by the trait. advertiseRoutines puts your routine in the list when someone clicks New in Scheduled Tasks. enhanceTaskItemForm adds your form fields to the task edit screen. standardRoutineHandler looks up the routine id in TASKS_MAP and calls your method. You do not write any of that yourself.
The method returns a status code. Status::OK marks the run as successful, Status::KNOCKOUT marks it as failed. Both end up in the task list, so an administrator sees a red line instead of having to guess why the folder is still full.
Note the realpath() check. The folder comes from a form field, and without that check a typo such as ../ sends the task into the parent directory of your site.
The form and the language strings
The form file is named after the form key in the map. The fields must sit inside fields name="params" and a fieldset called task_params, otherwise they do not appear on the task screen:
<?xml version="1.0" encoding="UTF-8"?>
<form>
<fields name="params">
<fieldset name="task_params">
<field name="folder" type="text" default="tmp" required="true"
label="PLG_TASK_CLEANFOLDER_FOLDER_LABEL"/>
<field name="days" type="number" default="30" min="1" filter="integer"
label="PLG_TASK_CLEANFOLDER_DAYS_LABEL"/>
</fieldset>
</fields>
</form>
The scheduler builds the title and description of your task type from langConstPrefix plus _TITLE and _DESC. Put these in the .ini file:
PLG_TASK_CLEANFOLDER="Task - Clean Folder"
PLG_TASK_CLEANFOLDER_DELETE_TITLE="Delete old files"
PLG_TASK_CLEANFOLDER_DELETE_DESC="Removes files older than a number of days from a folder."
PLG_TASK_CLEANFOLDER_FOLDER_LABEL="Folder (relative to the site root)"
PLG_TASK_CLEANFOLDER_DAYS_LABEL="Older than (days)"
When a task type shows up as a raw constant such as PLG_TASK_CLEANFOLDER_DELETE_TITLE, the prefix in the map and the keys in the file do not match.
Install, enable, schedule
Zip the folder and install it through System > Install > Extensions. Then go to System > Manage > Plugins, filter on type task and enable it. A freshly installed plugin is disabled, and a disabled task plugin simply does not appear in the list of task types. That is the number one reason people think their plugin is broken.
Now open System > Scheduled Tasks, click New and pick "Delete old files". Set the folder, the number of days and the execution rule, for example once a day at 03:00. The Run Test button in the task list runs it right away, which is the quickest way to see whether your code works.
Who actually starts the task
This is the part that surprises people. Joomla has no clock of its own. Something has to wake the scheduler, and there are three options in the Scheduled Tasks Options:
- Lazy scheduler. On by default. A small script on site pages checks whether a task is due and triggers it in the background. It needs visitors: on a quiet site your 03:00 task runs when the first visitor arrives at 07:40.
- Web cron. Joomla generates a URL with a secret key. An external cron service or your host's cron calls that URL. Keep the key out of public places.
- The command line. The most reliable option, if your host gives you a real cron job.
For the command line, add a cron line that runs every minute:
* * * * * php /path/to/site/cli/joomla.php scheduler:run
Without options it runs the next task that is due. scheduler:run --id=5 runs one specific task immediately, which is handy while testing. If you switch to cron or web cron, turn off the lazy scheduler, so tasks do not run from two sides. The Joomla CLI has more useful commands next to this one.
Long tasks are better off on the command line too. Through the lazy scheduler or web cron they run inside a normal web request, with the PHP time limit of your host.
When a task gets stuck
While a task runs, the scheduler locks it, so the same task cannot start twice. If PHP dies halfway, for example on a timeout or a fatal error, that lock stays and the task never runs again. The task list then keeps showing it as running.
Look in administrator/logs for the scheduler log first; your logTask() lines end up there, together with the error. If your version offers no unlock option in Scheduled Tasks, clear the locked column of that task in the #__scheduler_tasks table. Then fix the cause, or the next run gets stuck in the same place. If you are not sure which plugin is throwing the error, the debug console and the PHP error log tell you more than the task list does.
Once the first task plugin works, the second one is a copy with a different method. Most of those standalone cron scripts can move into Joomla this way, and with them the database passwords that were sitting in plain PHP files outside your site.
Doctor Joomla is a project of Cloudfaction, a Dutch agency building and maintaining Joomla sites since 1999. The articles are made with the help of AI, so tell us if something does not match your screen. Mail info@cloudfaction.nl if you want someone to look at yours.