Skip to main content

Event Bridge — Automatic Event Dispatch

The Event Bridge is MIH's no-code integration system. It lets administrators map any Moodle event to any external service call — without writing PHP.


How It Works

At a high level:

  1. A user does something in Moodle (logs in, completes a course, submits an assignment)
  2. Moodle fires an event (a PHP class extending \core\event\base)
  3. MIH's universal observer catches the event
  4. The observer looks up matching active rules in the database
  5. For each matching rule, an adhoc task is queued
  6. Moodle cron picks up the task and calls the MIH API
  7. The MIH API sends the payload to the configured service

The Universal Observer

The observer is registered in db/events.php against \core\event\base — the base class for every event in Moodle:

// db/events.php
$observers = [
[
'eventname' => '\core\event\base',
'callback' => '\local_integrationhub\event\observer::handle_event',
],
];

This means MIH catches:

  • All events from Moodle™ core (user, course, grade, enrolment, etc.)
  • All third-party plugin events
  • Any custom events you define in your own plugins

What the Observer Does

public static function handle_event(\core\event\base $event): void {
// 1. Get the event class name
$eventname = get_class($event);

// 2. Find active rules for this event
$rules = $DB->get_records('local_integrationhub_rules', [
'eventname' => $eventname,
'enabled' => 1,
]);

if (empty($rules)) {
return; // No rules — nothing to do
}

// 3. Deduplication check
$signature = sha1($eventname . $event->objectid . $event->userid . $event->crud);
$cache = \cache::make('local_integrationhub', 'event_dedupe');
if ($cache->get($signature)) {
return; // Already processed
}
$cache->set($signature, 1);

// 4. Queue one adhoc task per rule
foreach ($rules as $rule) {
$task = new \local_integrationhub\task\dispatch_event_task();
$task->set_custom_data([
'ruleid' => $rule->id,
'eventdata' => $event->get_data(),
]);
\core\task\manager::queue_adhoc_task($task);
}
}

Payload Templates

Templates define the JSON body sent to the external service. They use {{variable}} placeholders that are replaced with values from the event data at dispatch time.

Basic Template

{
"event": "{{eventname}}",
"user_id": {{userid}},
"timestamp": {{timecreated}}
}

Available Variables

These variables are available in every template, sourced from $event->get_data():

VariableTypeDescriptionExample
{{eventname}}stringFull event class name\core\event\user_created
{{userid}}intID of the user who triggered the event5
{{objectid}}intID of the primary object affected42
{{courseid}}intCourse ID (0 if not course-specific)10
{{contextid}}intMoodle context ID1
{{contextlevel}}intContext level (10=system, 50=course, etc.)50
{{timecreated}}intUnix timestamp of the event1708258939
{{ip}}stringIP address of the user192.168.1.100
{{crud}}stringOperation type: create, read, update, deletec
{{edulevel}}intEducational level (0=other, 1=teaching, 2=participating)2

Type-Aware Replacement

The template engine is type-aware:

  • Integers ({{userid}}, {{objectid}}, etc.) are replaced as raw numbers — do not wrap in quotes
  • Strings ({{eventname}}, {{ip}}) are JSON-escaped and should be wrapped in quotes
  • Booleans are replaced as true or false
{
"event_class": "{{eventname}}",
"user_id": {{userid}},
"object_id": {{objectid}},
"is_system": false,
"metadata": {
"course": {{courseid}},
"ip": "{{ip}}",
"time": {{timecreated}}
}
}

Default Template (No Template Set)

If no payload template is configured, the raw event data array is sent as-is:

{
"eventname": "\\core\\event\\user_created",
"userid": 5,
"objectid": 5,
"courseid": 0,
"contextid": 1,
"timecreated": 1708258939,
...
}

Deduplication

The observer uses Moodle's application cache to prevent duplicate processing.

Cache definition (db/caches.php):

$definitions = [
'event_dedupe' => [
'mode' => cache_store::MODE_APPLICATION,
'simplekeys' => true,
'ttl' => 60, // 60 seconds
],
];

Deduplication key:

$signature = sha1($eventname . $event->objectid . $event->userid . $event->crud);

If the same logical event fires twice within 60 seconds (same event class, same object, same user, same operation), only the first occurrence is processed.


Dispatch Task

The dispatch_event_task adhoc task handles the actual delivery:

Execution Flow

dispatch_event_task::execute()

├── Load rule from DB (check it still exists and is enabled)
├── Load service from DB (check it still exists and is enabled)

├── Prepare payload:
│ ├── If template is empty: use raw event data
│ └── If template exists:
│ ├── Replace {{variables}} with event data values
│ ├── Decode as JSON
│ └── If JSON is invalid: throw exception (task will retry)

├── Determine method:
│ ├── AMQP service: method = 'AMQP'
│ ├── Rule has http_method: use it
│ └── Default: 'POST'

├── mih::instance()->execute_request(service, endpoint, payload, method)

├── On success: mtrace success message, task completes

└── On failure:
├── Increment attempt counter in custom_data
├── If attempts < 5: rethrow (Moodle retries the task)
└── If attempts >= 5: move_to_dlq(), return (stop retrying)

Retry Behavior

Moodle's adhoc task system has its own retry mechanism. When dispatch_event_task rethrows an exception, Moodle will retry the task according to its own schedule (typically with increasing delays).

MIH tracks its own attempt counter in custom_data to enforce a maximum of 5 total attempts before giving up and writing to the DLQ.


Dead Letter Queue

When a task reaches 5 failed attempts, the payload is written to local_integrationhub_dlq:

protected function move_to_dlq($rule, $payload, $error): void {
global $DB;
$dlq = new \stdClass();
$dlq->eventname = $rule->eventname;
$dlq->serviceid = $rule->serviceid;
$dlq->payload = json_encode($payload);
$dlq->error_message = $error;
$dlq->timecreated = time();
$DB->insert_record('local_integrationhub_dlq', $dlq);
}

DLQ entries can be reviewed and replayed from the Queue tab in the dashboard.


Practical Examples

Notify Slack When a User Enrolls

Service: slack-webhook (REST, POST to Slack Incoming Webhook URL)

Event: \core\event\user_enrolment_created

Template:

{
"text": "New enrollment: User {{userid}} enrolled in course {{courseid}}",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*New Enrollment*\nUser ID: {{userid}}\nCourse ID: {{courseid}}\nTime: {{timecreated}}"
}
}
]
}

Publish to RabbitMQ on Course Completion

Service: rabbitmq-prod (AMQP)

Event: \core\event\course_completed

Endpoint (Routing Key): lms.events.course.completed

Template:

{
"event_type": "course_completed",
"user_id": {{userid}},
"course_id": {{courseid}},
"completed_at": {{timecreated}},
"source": "moodle"
}

Sync User to CRM on Profile Update

Service: crm-api (REST, PUT)

Event: \core\event\user_updated

Endpoint: /contacts/{{userid}}

Template:

{
"moodle_id": {{userid}},
"updated_at": {{timecreated}},
"source": "moodle_lms"
}

Limitations

  • Template variables are limited to the flat fields in $event->get_data(). Nested data (e.g., other array contents) is not directly accessible via {{variable}} syntax.
  • The observer fires on every Moodle event — in high-traffic systems, ensure your rules are specific to avoid unnecessary DB queries.
  • Deduplication is based on a 60-second window. Events that legitimately fire multiple times for different objects within 60 seconds will be correctly processed (the signature includes objectid).