Transform Learning with a Powerful Enterprise LMS

Learn more
Moodle

How to Add a Scheduled Task to a Moodle Plugin

A step-by-step Moodle scheduled task plugin tutorial: the task class, db/tasks.php schedules and the R value, upgrade behaviour, CLI runs, retries, the cron user, 5.3 constructor injection and PHPUnit tests.

Learn more about this topic with AI

Gemini opens with the prompt copied - paste it into the chat to start.

Step path illustration of adding a scheduled task to a Moodle plugin, from the task class and db/tasks.php to running and testing it

A Moodle scheduled task plugin feature needs four pieces. Create a class in your plugin's classes/task/ folder that extends \core\task\scheduled_task and implements get_name() and execute(), register it with a default schedule in db/tasks.php, add the task name as a language string, and bump the plugin version so Moodle reads the new definition on upgrade. Cron then runs the task on that schedule, administrators can change the schedule, and failures are retried automatically.

This tutorial builds one complete scheduled task in a local plugin, then covers schedules, error handling, the cron user, testing and the Moodle 5.3 change that lets tasks receive dependencies through their constructor. It is written for plugin developers who know the basic plugin layout; if you are starting from nothing, our Moodle local plugin tutorial builds the skeleton first. Checked against the Moodle 5.3.0 source (\core\task\scheduled_task, \core\task\manager and admin/cli/scheduled_task.php) and the Task API developer documentation (October 2026). The code below has not been run on a live site; test it on staging first.

When a scheduled task is the right tool

Moodle's Task API has two kinds of task, and picking the right one saves rework.

Scheduled taskAd hoc task
RunsOn a repeating schedule, such as nightly at 02:00Once, as soon as possible or at a set time, after code queues it
ConfigurationThe same every run; schedule editable by adminsCustom data per queued instance
ConcurrencyOne instance at a timeMany instances can run in parallel
Typical useCleanup, sync with an external system, digest emails, report buildingProcessing triggered by a user action, such as a large import or a course reset

A common pattern combines them: a scheduled task finds the work that is due and queues an ad hoc task per item, so one slow item does not hold up the rest. If your queue grows faster than it drains, see our guide to an ad hoc task queue that keeps piling up.

Step 1: Write the task class

The class goes in classes/task/, so Moodle's autoloader finds it by namespace. This example belongs to a local plugin, local_example, that keeps a log table local_example_log (created by the plugin's db/install.xml) and should delete rows older than a configurable number of days.

<?php
namespace local_example\task;

/**
 * Deletes local_example_log rows older than the retention period.
 *
 * @package    local_example
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */
class purge_old_records extends \core\task\scheduled_task {

    #[\Override]
    public function get_name(): string {
        return get_string('task_purgeoldrecords', 'local_example');
    }

    #[\Override]
    public function execute() {
        global $DB;

        $days = (int) get_config('local_example', 'retentiondays');
        if ($days <= 0) {
            mtrace('Retention period not set; nothing to delete.');
            return;
        }

        $cutoff = \core\di::get(\core\clock::class)->time() - ($days * DAYSECS);
        $params = ['cutoff' => $cutoff];
        $count = $DB->count_records_select('local_example_log', 'timecreated < :cutoff', $params);
        $DB->delete_records_select('local_example_log', 'timecreated < :cutoff', $params);

        mtrace("Deleted {$count} local_example_log records older than {$days} days.");
    }
}

Points worth copying:

  • get_name() returns a language string, because administrators see it on the Scheduled tasks page.
  • execute() does the work. Use the DML API with placeholders, as here; never build SQL from config values by concatenation.
  • mtrace() writes to the task log, which is where an administrator will look first when something goes wrong.
  • The clock comes from \core\clock through the dependency injection container rather than time(), so tests can freeze it. The container has been in core since Moodle 4.4.

Add the name to lang/en/local_example.php:

$string['task_purgeoldrecords'] = 'Purge old example log records';

Step 2: Register it in db/tasks.php

db/tasks.php holds an array of task definitions. Each needs a classname; the schedule fields are minute, hour, day, month and dayofweek.

<?php
defined('MOODLE_INTERNAL') || die();

$tasks = [
    [
        'classname' => 'local_example\task\purge_old_records',
        'minute' => 'R',
        'hour' => '2',
        'day' => '*',
        'month' => '*',
        'dayofweek' => '*',
    ],
];

Each field accepts the familiar cron-style syntax: *, a number, a range such as 1-5, a step such as */15, or a comma-separated list. Two Moodle-specific details:

  • R picks a random value when the task is installed or updated, and that value is then fixed. Using 'minute' => 'R' spreads the load, so every site with your plugin does not hit an external API at the same second.
  • Omitted fields default to *. In the 5.3 source every schedule field of scheduled_task defaults to *, so a definition with only a classname runs every minute. Always set the fields you mean.

Two optional keys are useful. 'disabled' => 1 installs the task switched off until an administrator enables it, which suits tasks only some sites need. The blocking key still appears in older examples, but the Task API documentation states that blocking tasks have not been supported since Moodle 4.4; leave it out.

Step 3: Bump the version and upgrade

Moodle only reads db/tasks.php during install or upgrade. Increase $plugin->version in version.php, then run the upgrade from the Notifications page or with php admin/cli/upgrade.php from the project root.

During the upgrade, \core\task\manager::reset_scheduled_tasks_for_component() syncs the component's definitions into the task_scheduled table. Three behaviours follow from that code:

  • A new task is inserted, with its first run calculated from the schedule.
  • An existing task gets your new default schedule unless an administrator has customised it; customised schedules are left alone.
  • A task you remove from db/tasks.php is deleted from task_scheduled.

So changing a default schedule in a later release does not override sites that tuned it. That is deliberate, and worth stating in your release notes.

Step 4: Run and inspect the task

You do not need to wait for the schedule. From the Moodle project root (on 5.1 and later, the CLI scripts are not in public/), run as the web server user:

php admin/cli/scheduled_task.php --list
php admin/cli/scheduled_task.php --execute='\local_example\task\purge_old_records' --showdebugging

The script also has --showsql to print queries and --force to run a task while cron is disabled. The Task API documentation notes that without --showdebugging, errors can be hidden and a misleading message about locks shown instead, so keep that flag on while developing.

In the browser, the task appears under Site administration > Server > Tasks > Scheduled tasks, with its schedule, last and next run, and a log link. Our admin guide to scheduled tasks and task logs covers what administrators can change there, and the $CFG->scheduled_tasks override in config.php lets a site fix a schedule or disable a task in code, including by wildcard such as '\local_example\*'.

Errors, retries and long-running work

Let exceptions escape for whole-task failures. If execute() throws, the task system logs it and schedules a retry. The documentation says the first retry is after one minute and the delay grows with each failure, up to 24 hours. You do not need your own retry loop.

Catch per item when processing many things. If the task loops over courses or users, one bad record should not stop the rest. Catch the exception, log it with mtrace_exception($e), continue, and decide at the end whether the run as a whole failed. If it did, throw, so the failure is visible and retried. Core's \quiz_statistics\task\recalculate is the example the documentation points to.

Watch static caches. Some Moodle APIs cache in static variables for the length of a request, and a cron process can run many tasks. After a task that changes a lot of data, such as enrolments, call \core\task\manager::clear_static_caches() at the end, which makes the runner exit so the next task starts in a fresh process.

Show progress for long tasks. Since Moodle 4.5, the \core\task\stored_progress_task_trait trait stores progress that the Tasks running now page renders as a progress bar. Call $this->start_stored_progress(), then $this->progress->update($done, $total, $message) inside the loop.

Keep each run bounded. Delete or process in batches with a limit, so a backlog of millions of rows is cleared over several runs instead of one run that holds the task lock for hours. A task that seems to never finish usually ends up in our stuck or locked scheduled task guide.

Users, permissions and disabled plugins

Tasks run as the cron user, which is the main administrator by default. That has two consequences.

  • Capability checks need a real user. If the task acts on behalf of someone, pass that user's ID to require_capability() or has_capability(), or switch user with \core\cron::setup_user($user). Older examples, including the current Task API page, call cron_setup_user(); in recent Moodle versions that global function has been moved to \core\cron::setup_user(), and it is not defined in the 5.3 source.
  • Output is safe. The Output API can be used in cron tasks since Moodle 3.5, so rendering an email template inside a task is fine.

By default a task does not run when its plugin is disabled. That matters for plugin types that can be switched off, such as enrolment or authentication plugins. If cleanup must continue while the plugin is disabled, override get_run_if_component_disabled() to return true.

Constructor injection in Moodle 5.3

From Moodle 5.3 (MDL-89528), the task manager builds task instances through the dependency injection container, so a scheduled task can declare its dependencies in the constructor:

class purge_old_records extends \core\task\scheduled_task {
    public function __construct(
        private readonly \core\clock $clock,
    ) {
    }

    // get_name() as before; in execute(), use $this->clock->time().
}

Use this only if $plugin->requires is Moodle 5.3 or later. Constructor injection for tasks arrived with that 5.3 change, so on earlier branches a required constructor argument would break the task. For a plugin that supports 4.4 to 5.2 as well, keep calling \core\di::get() inside execute(), as in Step 1.

Testing the task with PHPUnit

Call execute() directly from an advanced_testcase. In PHPUnit, mtrace() echoes its output, so you can assert on the log line too.

<?php
namespace local_example\task;

/**
 * @covers \local_example\task\purge_old_records
 */
final class purge_old_records_test extends \advanced_testcase {
    public function test_execute_deletes_only_old_records(): void {
        global $DB;
        $this->resetAfterTest();
        $clock = $this->mock_clock_with_frozen(1800000000);
        set_config('retentiondays', 30, 'local_example');

        $DB->insert_record('local_example_log', ['timecreated' => $clock->time() - 31 * DAYSECS]);
        $keep = $DB->insert_record('local_example_log', ['timecreated' => $clock->time() - DAYSECS]);

        $task = \core\task\manager::get_scheduled_task(purge_old_records::class);
        $this->expectOutputRegex('/Deleted 1 local_example_log records/');
        $task->execute();

        $this->assertEquals([$keep], array_keys($DB->get_records('local_example_log')));
    }
}

mock_clock_with_frozen() replaces the clock in the container, so the task and the test agree on "now". Fetching the task with \core\task\manager::get_scheduled_task() also checks that your db/tasks.php entry is installed. The insert assumes the table has only the columns shown; add any required fields from your schema.

For the wider picture of Moodle development, from plugin types to events and APIs, see our Moodle guide.

How we can help

We build and review Moodle plugins, including scheduled and ad hoc tasks that sync with HR systems, CRMs and payment platforms, with PHPUnit coverage and upgrade-safe schedules. See our Moodle development services to talk about your plugin.

Frequently asked questions

Where do scheduled task classes go in a Moodle plugin?

In the plugin's classes/task/ folder, in the namespace <component>\task, extending \core\task\scheduled_task. The class is then registered in db/tasks.php.

Why is my new scheduled task not showing in Moodle?

Moodle reads db/tasks.php during install and upgrade. Increase the plugin version in version.php and run the upgrade, then check the class name in db/tasks.php matches the class exactly.

What does R mean in db/tasks.php?

It selects a random value for that field when the task is installed or updated. The value is then fixed, so each site runs the task at its own time, which spreads load on shared services.

How do I run a Moodle scheduled task manually?

From the project root, run php admin/cli/scheduled_task.php --execute='\local_example\task\purge_old_records' --showdebugging as the web server user, using your own class name. Administrators can also use Run now on the Scheduled tasks page.

Free 30-minute consultation

Not sure where to start? Talk to an LMS engineer.

Tell us what platform you run and what you need. You’ll get honest, practical advice - even if the answer is that you don’t need us.

Sources & references

  1. Task API - Moodle HQ
  2. Scheduled tasks - Moodle HQ
  3. db/tasks.php - Moodle HQ
  4. lib/classes/task/scheduled_task.php (Moodle 5.3.0) - Moodle HQ
  5. admin/cli/scheduled_task.php (Moodle 5.3.0) - Moodle HQ
Add as a preferred source on Google

Add All-in-One LMS as a preferred source to see more of our articles in Google’s Top Stories.

Learn more about this topic with AI

Gemini opens with the prompt copied - paste it into the chat to start.

Keep reading

Before you go - get a free LMS audit

Tell us which LMS you use and what is not working. An engineer replies within one business day.

Choose which optional cookies we may use. You can change this at any time from "Cookie preferences" in the footer. Cookie policy · Privacy policy