Sometimes you need Moodle to do something extra when a particular thing happens: add every new user to a cohort, push a course completion to an external system, or send a tailored notification when someone enrolls. The wrong way to do this is to edit a core file. That change is invisible to the next person, it breaks on upgrade, and it puts you outside supported Moodle.

The right way is an event observer. Moodle fires a typed event for almost everything that happens in the system, and any plugin can register a small handler that runs when a given event is dispatched. Your code lives entirely in your own plugin, so it survives upgrades and is easy to reason about.

This guide walks through building a minimal observer plugin from scratch, using a real example: when a user is created, read a profile field and add them to the cohort that matches it. Routing users into the right cohort by a characteristic such as region or department is genuinely useful automation, since that cohort can then drive enrollments through cohort sync, target audiences, or reports. Core has no built-in setting for it, and the event hands you the user you need to act on.

What an event is

When something significant happens, core triggers an event object, for example coreeventuser_created or coreeventcourse_completed. The event carries structured data: who did it, which object it concerns, and a small bag of extra fields. You can browse every event your site fires under Site administration, Reports, Events list. That page is the canonical list of event class names for your exact version, which matters because the names have shifted over the years.

An observer is a static method you register against an event name. Moodle calls it and passes the event object. That is the whole model.

The plugin skeleton

An observer needs to live in a plugin. A local plugin is the natural home for site-specific glue like this. The minimum is three files under local/autocohort/.

First, version.php, which tells Moodle the plugin exists and what it needs:

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

$plugin->component = 'local_autocohort';
$plugin->version   = 2026110300;
$plugin->requires  = 2024100700; // Moodle 4.5.
$plugin->maturity  = MATURITY_STABLE;
$plugin->release   = '1.0';

Second, a language file at lang/en/local_autocohort.php so the plugin has a name in the admin interface:

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

$string['pluginname'] = 'Automatic cohort assignment';

Third, the part that does the work: the event registration and the handler.

Registering the observer

Observers are declared in db/events.php. It returns an array of observer definitions. Each one ties an event name to a callback:

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

$observers = array(
    array(
        'eventname' => 'coreeventuser_created',
        'callback'  => 'local_autocohortobserver::user_created',
    ),
);

Two optional keys are worth knowing about. priority controls the order when several observers watch the same event, higher first. internal controls when your handler runs, and it is worth understanding, so it gets its own section below. The default for both is usually what you want, so leave them out unless you have a reason.

The handler

The callback is a static method on a class Moodle can autoload. Place it at classes/observer.php, and the namespace follows from the plugin name. This handler reads a custom profile field, here one with the shortname region, and adds the user to the cohort whose idnumber matches that value:

<?php
namespace local_autocohort;

defined('MOODLE_INTERNAL') || die();

class observer {

    /** Shortname of the custom profile field that drives cohort membership. */
    const PROFILE_FIELD = 'region';

    /**
     * Add a newly created user to the cohort matching their profile field.
     *
     * @param coreeventuser_created $event
     */
    public static function user_created(coreeventuser_created $event) {
        global $CFG, $DB;
        require_once($CFG->dirroot . '/user/profile/lib.php');
        require_once($CFG->dirroot . '/cohort/lib.php');

        // objectid is the id of the user that was just created. Custom profile
        // data is already saved at this point in the standard creation paths.
        $profile = profile_user_record($event->objectid, false);

        $region = '';
        if (isset($profile->{self::PROFILE_FIELD} {
            $region = trim($profile->{self::PROFILE_FIELD});
        }
        if ($region === '') {
            // No value set, nothing to do.
            return;
        }

        // Convention: the cohort's idnumber matches the profile field value.
        $cohort = $DB->get_record('cohort', array('idnumber' => $region;
        if (!$cohort) {
            return;
        }

        cohort_add_member($cohort->id, $event->objectid);
    }
}

A few things to note. The event object is read only, so you take data out of it but never change it. The user id comes straight from $event->objectid, with no guessing and nothing to match against, because the event is about that exact user. Driving the cohort off the profile field value, by matching it against the cohort idnumber, means you never touch the code again to onboard a new region: an administrator just creates a cohort whose idnumber matches the field value. And cohort_add_member already ignores duplicates, so there is no need to check membership first.

One timing point is worth knowing. This works because custom profile data is saved before the user_created event fires in the standard creation paths. The add-user form, self-registration, and bulk upload all save the profile data first and then trigger the event. If you also want to react when an existing user’s field changes later, observe coreeventuser_updated as well, with its own handler.

To try it: add a custom profile field with the shortname region under Site administration, Users, User profile fields. Create a cohort whose idnumber is one of the field values, say emea. Then create a user with region set to emea. They land in the cohort automatically.

When your handler runs: the internal flag

The internal flag decides the moment your observer fires, and it is worth understanding before you build anything heavier than this.

When internal is true, which is the default if you omit it, Moodle calls your observer immediately, in the same database transaction as the action that triggered the event. That is exactly right for fast local work like adding a cohort member. It is quick, it is part of the same atomic operation, and if the surrounding action rolls back, your change rolls back with it.

When internal is false, Moodle buffers the event and dispatches it after the surrounding transaction commits. You want this for anything that should only happen once the triggering action is definitely saved, for example talking to an external system.

One rule follows from this: never do slow or external work directly inside an observer, whatever the flag. An HTTP call to another system inside a synchronous observer blocks the user’s request and can wedge a transaction. For that kind of work, have the observer do the cheap part, capture the ids it needs, and hand the slow part off to an ad hoc task that runs on cron. The observer stays fast, and the heavy lifting happens out of band.

Activating the plugin

Moodle caches the observer list, so dropping the files in place is not enough. The plugin has to be installed and the caches rebuilt:

  1. Copy the plugin into place at local/autocohort/.
  2. Run the upgrade so Moodle registers the new plugin and its observers. On the command line that is admin/cli/upgrade.php, or just visit the notifications page as an administrator.
  3. Purge caches with admin/cli/purge_caches.php, or through Site administration, Development, Purge caches.

Whenever you change db/events.php afterward, bump the version number in version.php and run the upgrade again. The observer cache only rebuilds when the plugin version changes, so a forgotten version bump is the usual reason a new observer never seems to fire.

Where this pattern fits

The same three-file shape covers a huge range of needs. Swap the event name and the handler body, and you can react to enrollments, completions, logins, grade changes, or anything else in the events list. Reach for it when you want behavior that fires off the back of a normal Moodle action, when the event carries the data you need, and when you would rather not touch core. Because everything lives in your own plugin, you get behavior core does not offer without giving up clean upgrades. That trade is almost always the right one.

Solin builds and maintains custom Moodle and Totara plugins, including event-driven integrations with external systems. If you would rather have this handled end to end, we are happy to help.

Need help with a Moodle or Totara project?

Contact us