06 — Events, Hooks, Tasks & Messages : le système nerveux de Moodle
Chapitre 06 de la partie Architecture — Moodle 5.2, PHP 8.3+. Précédent : 05 — Fichiers, strings, config · Suivant : 07 — Cache, sessions, sécurité
Si les chapitres précédents décrivaient le squelette de Moodle (arborescence, plugins, base de données, output), celui-ci décrit son système nerveux : comment les différentes parties de la plateforme communiquent entre elles sans se connaître. Quatre API distinctes, quatre philosophies :
- Events (Events 2) : « quelque chose s’est produit » — notification immuable du passé, base des logs et de l’analytics.
- Hooks (depuis 4.4) : « quelque chose va se produire, voulez-vous l’influencer ? » — point d’extension mutable, remplaçant moderne des callbacks magiques de
lib.php. - Tasks : « quelque chose doit se produire plus tard » — travaux planifiés (cron) ou à la demande (file d’attente).
- Messages : « quelqu’un doit être informé » — notifications multi-canaux (web, email, mobile) avec préférences par utilisateur.
Maîtriser la frontière entre ces quatre API est ce qui distingue un développeur Moodle expérimenté d’un débutant qui met tout dans des events ou, pire, dans des fonctions lib.php.
Mini-sommaire
- Partie A — Events 2
- Partie B — Hooks (4.4+)
- Partie C — Task API
- Partie D — Message API
- Résumé — points clés
Partie A — Events 2
1. Philosophie : le passé, immuable et non annulable
L’API Events (dite « Events 2 », introduite en Moodle 2.6 pour remplacer un premier système bien plus fruste) repose sur un principe simple et strict : un event décrit quelque chose qui s’est déjà produit. Pas quelque chose qui va se produire, pas quelque chose qu’on aimerait empêcher. Le fait est accompli, l’event en est le procès-verbal.
Trois conséquences directes :
- Nommage au passé :
user_created,course_viewed,attempt_submitted. Jamaiscreate_usernibefore_user_creation. - Fire-and-forget : le code qui déclenche l’event ne récupère aucune valeur de retour et ne doit jamais dépendre de ce que font (ou ne font pas) les observers. Si un observer plante, l’action d’origine reste valide.
- Immuabilité : une fois déclenché, un event ne peut plus être modifié. Les observers le lisent, point.
Cette rigueur n’est pas gratuite : elle fait des events la source unique de vérité pour les logs. Chaque event déclenché est capté par le log store standard et écrit en base. C’est aussi sur les events que reposent l’API analytics (prédictions d’abandon, etc.), les rapports de participation, la complétion d’activité et une bonne partie des intégrations tierces (webhooks, xAPI/LRS via des plugins logstore).
💡 Pour un dev React : les events Moodle sont l’équivalent d’un pipeline analytics (Segment, PostHog) combiné à un event emitter Node. Vous émettez
analytics.track('Course Viewed', {...})sans savoir qui consomme — un dashboard, un webhook, un data warehouse. Idem ici :->trigger()émet, les observers (dont le logstore) consomment. Et comme avec Segment, la règle d’or est la même : l’émission ne doit jamais casser le flux principal, et le payload doit être sérialisable.
La différence fondamentale avec les hooks mérite d’être gravée dès maintenant, car c’est LA question d’architecture qui revient sans cesse :
| Event | Hook | |
|---|---|---|
| Temporalité | Passé (« c’est fait ») | Présent (« c’est en cours, influencez-le ») |
| Mutabilité | Immuable | Mutable (les callbacks modifient le hook) |
| Retour vers l’appelant | Aucun | Oui (l’appelant relit le hook après dispatch) |
| Annulable | Non | Parfois (hooks « stoppables ») |
| Usage typique | Logs, notifications, synchronisations | Injecter du HTML, modifier une config, filtrer une liste |
Si votre plugin veut savoir qu’un utilisateur a été créé pour le synchroniser vers un CRM : event + observer. Si votre plugin veut modifier le HTML du <head> de chaque page : hook. Si vous hésitez, demandez-vous : « est-ce que le code appelant a besoin du résultat de mon intervention ? » Oui → hook. Non → event.
2. Déclarer un event
Un event est une classe PHP dans le namespace \{component}\event\, donc physiquement dans classes/event/ du plugin, chargée par l’autoloader (aucune déclaration dans un fichier db/ n’est nécessaire pour définir un event — contrairement aux observers, tasks et messages qu’on verra plus loin).
Voici un exemple complet et commenté pour un plugin d’activité hypothétique mod_recette (gestion de recettes de cuisine), où l’on veut tracer la publication d’une recette :
<?php
// Fichier : mod/recette/classes/event/recipe_published.php
namespace mod_recette\event;
/**
* Event déclenché quand une recette est publiée.
*
* Convention de nommage : objet_verbe_au_passé (recipe_published,
* user_created, attempt_submitted...). Le nom de classe EST le nom
* de l'event : \mod_recette\event\recipe_published.
*
* @property-read array $other {
* Données additionnelles (documentez-les toujours ainsi !) :
* - string title : titre de la recette au moment de la publication
* - int difficulty : niveau de difficulté (1-5)
* }
*/
class recipe_published extends \core\event\base {
/**
* Métadonnées statiques de l'event. Appelée par le constructeur
* de base — vous ne définissez QUE les données invariables ici.
*/
protected function init() {
// 'c', 'r', 'u' ou 'd' : nature CRUD de l'action sous-jacente.
// Publier une recette = mise à jour de son état → 'u'.
$this->data['crud'] = 'u';
// Niveau pédagogique, utilisé pour filtrer les rapports :
// - LEVEL_TEACHING : action d'enseignement (un prof publie)
// - LEVEL_PARTICIPATING : action d'apprentissage (un étudiant consulte)
// - LEVEL_OTHER : ni l'un ni l'autre (action admin/technique)
$this->data['edulevel'] = self::LEVEL_TEACHING;
// Table SQL de l'objet concerné par objectid. Obligatoire dès
// que vous renseignez objectid à la création. Sans préfixe.
$this->data['objecttable'] = 'recette_recipes';
}
/**
* Nom lisible de l'event, affiché dans les rapports de logs.
* TOUJOURS via get_string() — les logs sont multilingues.
* Méthode STATIQUE : ne peut pas dépendre des données d'instance.
*/
public static function get_name() {
return get_string('eventrecipepublished', 'mod_recette');
}
/**
* Description d'une occurrence précise, affichée dans le rapport
* de logs détaillé. Ici on a accès aux données d'instance.
* Convention : phrase en anglais, ids entre quotes, pas de HTML.
*/
public function get_description() {
return "The user with id '{$this->userid}' published the recipe " .
"with id '{$this->objectid}' in the recette activity with " .
"course module id '{$this->contextinstanceid}'.";
}
/**
* URL vers laquelle pointer depuis le rapport de logs pour
* « voir » l'objet concerné. Facultatif mais fortement recommandé.
*/
public function get_url() {
return new \moodle_url('/mod/recette/view.php', [
'id' => $this->contextinstanceid,
'recipe' => $this->objectid,
]);
}
/**
* Validation supplémentaire des données, exécutée UNIQUEMENT quand
* le debugging développeur est actif. Levez des coding_exception
* pour attraper les oublis pendant le dev, sans coût en prod.
*/
protected function validate_data() {
parent::validate_data();
if (!isset($this->other['title'])) {
throw new \coding_exception(
"The 'title' value must be set in other."
);
}
if (!isset($this->other['difficulty'])) {
throw new \coding_exception(
"The 'difficulty' value must be set in other."
);
}
}
/**
* Mapping pour la restauration de sauvegardes : indique comment
* ré-encoder objectid quand un log est restauré dans un autre
* cours (les ids changent). Voir le chapitre backup/restore.
*/
public static function get_objectid_mapping() {
return ['db' => 'recette_recipes', 'restore' => 'recette_recipe'];
}
public static function get_other_mapping() {
// 'title' et 'difficulty' ne référencent pas d'ids → rien à mapper.
return [];
}
}Passons en revue les propriétés standard disponibles sur tout event (accessibles en lecture via les méthodes magiques de \core\event\base) :
| Propriété | Rôle | Renseignée par |
|---|---|---|
objectid | Id de la ligne concernée dans objecttable | Vous, à la création |
contextid / context | Contexte où l’action a eu lieu (obligatoire) | Vous ('context' => $context) |
contextinstanceid | Id d’instance du contexte (ex. cmid pour un module) | Déduit du contexte |
userid | Utilisateur acteur (qui a fait l’action) | $USER->id par défaut, surchargeable |
relateduserid | Utilisateur affecté (si différent de l’acteur) | Vous |
courseid | Cours concerné | Déduit du contexte |
other | Données libres additionnelles | Vous |
timecreated | Timestamp | Automatique |
crud, edulevel, objecttable | Métadonnées | init() |
⚠️ Piège : la propriété
otherne doit contenir que des scalaires et des tableaux de scalaires — jamais d’objets, jamais de closures, jamais destdClass. Pourquoi ? Parce queotherest sérialisé (JSON) pour être écrit dans la table de logs, et désérialisé quand un event est reconstruit depuis un log (\core\event\base::restore()). Un objet passerait au premiertrigger()… puis exploserait ou perdrait ses données à la relecture. En mode debug,validate_data()de la classe de base vous le reprochera ; en prod, vous auriez une corruption silencieuse. Même contrainte d’esprit que les props sérialisables entre Server et Client Components en React.
Events core importants que vous croiserez ou observerez le plus souvent :
\core\event\course_viewed— un cours est consulté (déclenché par chaque page de cours) ;\core\event\user_created,\core\event\user_updated,\core\event\user_deleted— cycle de vie des comptes ;\core\event\user_loggedin,\core\event\user_loggedout— sessions ;\core\event\course_created,\core\event\course_updated,\core\event\course_deleted;\core\event\course_module_created,\core\event\course_module_updated,\core\event\course_module_deleted— ajout/modif/suppression d’activités ;\core\event\user_enrolment_created,\core\event\user_enrolment_deleted— inscriptions ;\core\event\course_module_completion_updated— complétion d’activité ;- côté modules, la famille des
\mod_xxx\event\course_module_viewed(chaque module DOIT déclencher le sien dans sonview.php).
Notez aussi l’existence de classes d’events abstraites dans le core, pensées pour être héritées par les plugins : \core\event\course_module_viewed, \core\event\course_module_instance_list_viewed… Hériter de ces classes plutôt que de base donne gratuitement le bon comportement pour les rapports standards :
<?php
// mod/recette/classes/event/course_module_viewed.php
namespace mod_recette\event;
class course_module_viewed extends \core\event\course_module_viewed {
protected function init() {
$this->data['objecttable'] = 'recette'; // table d'instances du module
parent::init(); // crud='r', LEVEL_PARTICIPATING
}
}3. Déclencher un event
Le cycle de vie est en deux temps : créer (avec les données) puis déclencher. La création se fait via la factory statique ::create() :
<?php
// Quelque part dans mod/recette/classes/manager.php, après avoir
// réellement publié la recette en base de données.
// 1. Écrire la donnée D'ABORD. L'event décrit le passé : au moment
// du trigger, la base doit déjà refléter le nouvel état.
$recipe->published = 1;
$recipe->timemodified = time();
$DB->update_record('recette_recipes', $recipe);
// 2. Créer l'event avec ses données d'instance.
$event = \mod_recette\event\recipe_published::create([
'context' => \context_module::instance($cm->id), // obligatoire
'objectid' => $recipe->id, // ligne concernée
// 'userid' => $USER->id est implicite ; précisez-le si l'acteur
// n'est pas l'utilisateur courant (script CLI, tâche cron...).
'other' => [
'title' => $recipe->title,
'difficulty' => (int) $recipe->difficulty,
],
]);
// 3. (Optionnel mais recommandé) Fournir un instantané de la ligne :
// les observers qui voudraient relire l'objet l'obtiendront sans
// requête SQL supplémentaire via $event->get_record_snapshot().
$event->add_record_snapshot('recette_recipes', $recipe);
// 4. Déclencher. À partir d'ici l'event est scellé : toute tentative
// de modification lèvera une exception.
$event->trigger();Quelques précisions importantes :
create()valide et fige. Elle vérifie la cohérence (contexte présent,objectidaccompagné d’objecttable, types corrects) et appelle votrevalidate_data()en mode debug. Aprèstrigger(), l’objet est verrouillé.add_record_snapshot($table, $record)est une pure optimisation : elle attache une copie mémoire de l’enregistrement à l’event. Un observer (exécuté dans la même requête PHP) peut alors appeler$event->get_record_snapshot('recette_recipes', $event->objectid)et récupérer l’objet sans toucher la base. Indispensable pour les events de suppression : au moment où l’observer s’exécute, la ligne n’existe plus en base, le snapshot est le seul moyen d’y accéder. Attention : les snapshots ne survivent pas à la requête (ils ne sont pas sérialisés dans les logs).create_from_record()et consorts : il n’existe pas de factory générique universelle dansbase, mais la convention (largement suivie dans le core) est d’ajouter à vos classes d’event des factories statiques dédiées qui encapsulent la construction ET le snapshot :
<?php
// Dans la classe recipe_published :
public static function create_from_recipe(\stdClass $recipe, \context_module $context): self {
$event = self::create([
'context' => $context,
'objectid' => $recipe->id,
'other' => ['title' => $recipe->title, 'difficulty' => (int) $recipe->difficulty],
]);
$event->add_record_snapshot('recette_recipes', $recipe);
return $event;
}
// Côté appelant, c'est plus propre et impossible à mal remplir :
recipe_published::create_from_recipe($recipe, $context)->trigger();⚠️ Piège : quand déclencher ? Toujours après que la donnée est écrite, et idéalement après le commit de la transaction. Si vous déclenchez un event à l’intérieur d’une transaction (
$DB->start_delegated_transaction()), Moodle vous protège partiellement : les observers marqués'internal' => falsesont mis en file et ne seront exécutés qu’après le commit réel (et jetés en cas de rollback). Mais les observers internal (le défaut !) et l’écriture des logs, eux, suivent le flux normal. Le pire anti-pattern : déclencheruser_createdpuis faire un rollback — vous auriez notifié le monde entier d’un utilisateur… qui n’existe pas. Écrivez, commitez, déclenchez.
4. Observers : réagir aux events
Un observer est le consommateur : une méthode statique enregistrée pour un ou plusieurs events. L’enregistrement se fait dans le fichier db/events.php du plugin qui écoute (pas de celui qui émet — les deux ne se connaissent pas) :
<?php
// Fichier : local/crmsync/db/events.php
// Déclare les observers du plugin local_crmsync.
defined('MOODLE_INTERNAL') || die();
$observers = [
[
// Nom complet de la classe d'event, AVEC le backslash initial.
'eventname' => '\core\event\user_created',
// Callback : méthode statique 'classe::méthode' (autoloadée).
'callback' => '\local_crmsync\observer::user_created',
// internal = false → l'observer n'est appelé qu'APRÈS le commit
// de toute transaction en cours. Obligatoire dès que vous parlez
// au monde extérieur (API HTTP, mail...) : vous ne voulez pas
// pousser vers le CRM un utilisateur dont l'INSERT sera rollback.
'internal' => false,
// Priorité : les observers d'un même event sont appelés par
// priorité DÉCROISSANTE (défaut 0). Rarement utile.
'priority' => 0,
// includefile : fichier à require avant l'appel. Legacy —
// inutile si votre callback est une classe autoloadée. Omettez-le.
// 'includefile' => null,
],
[
'eventname' => '\core\event\user_updated',
'callback' => '\local_crmsync\observer::user_updated',
'internal' => false,
],
[
// Wildcard : cet observer reçoit TOUS les events. Réservé aux
// cas systémiques (log stores, connecteurs xAPI). Coûteux !
'eventname' => '*',
'callback' => '\local_crmsync\observer::catch_all',
],
];Et la classe observer correspondante :
<?php
// Fichier : local/crmsync/classes/observer.php
namespace local_crmsync;
/**
* Observers du plugin local_crmsync. Convention : une classe
* 'observer' (ou des classes dédiées) avec des méthodes statiques
* portant le nom de l'event observé.
*/
class observer {
/**
* Réagit à la création d'un utilisateur : met en file une tâche
* adhoc de synchronisation vers le CRM.
*
* Le paramètre est TYPÉ avec la classe d'event concrète : vous
* bénéficiez de l'autocomplétion et d'une garantie de type.
*
* @param \core\event\user_created $event
*/
public static function user_created(\core\event\user_created $event): void {
// Les données de l'event sont en lecture seule.
$userid = $event->objectid; // id du nouvel utilisateur
$actor = $event->userid; // qui l'a créé (admin ? script ?)
// RÈGLE D'OR n° 1 : un observer doit être RAPIDE. Il s'exécute
// de façon synchrone dans la requête de l'utilisateur ! Tout
// travail lourd (appel HTTP au CRM) part en tâche adhoc.
$task = new \local_crmsync\task\push_user_task();
$task->set_custom_data(['userid' => $userid, 'action' => 'create']);
\core\task\manager::queue_adhoc_task($task, true);
}
public static function user_updated(\core\event\user_updated $event): void {
$task = new \local_crmsync\task\push_user_task();
$task->set_custom_data(['userid' => $event->objectid, 'action' => 'update']);
\core\task\manager::queue_adhoc_task($task, true);
}
/**
* Observer wildcard : reçoit le type abstrait \core\event\base.
* À vous de discriminer avec instanceof si besoin.
*/
public static function catch_all(\core\event\base $event): void {
// RÈGLE D'OR n° 2 : ne JAMAIS lever d'exception volontairement
// ni laisser fuiter une erreur récupérable. Moodle attrape les
// exceptions des observers et continue (l'action d'origine
// n'est pas annulée), mais cela pollue les logs et peut masquer
// de vrais problèmes.
if ($event->crud === 'd') {
debugging('Deletion event observed: ' . $event->eventname, DEBUG_DEVELOPER);
}
}
}💡 Pour un dev React :
db/events.phpjoue exactement le rôle d’unemitter.on('user:created', handler)— sauf que l’abonnement est déclaratif et découvert statiquement (fichierdb/), pas impératif au runtime. C’est le même choix de design que lesexportsconditionnels d’unpackage.jsonface à unimport()dynamique : Moodle peut construire la table complète des abonnements à l’install et la mettre en cache, sans exécuter le moindre code plugin. Le flaginternal => false, lui, ressemble aux callbacksafterCommitdes ORM (Prisma$transaction, SequelizeafterCommit) : ne parle au monde extérieur qu’une fois la transaction réellement commitée.
Point crucial — la purge des caches : la liste des observers est mise en cache agressivement. Après toute modification de db/events.php, vous devez soit incrémenter la version dans version.php du plugin (puis passer par la page d’upgrade), soit purger les caches (php admin/cli/purge_caches.php ou Administration du site → Développement → Purger les caches). En dev, avec $CFG->debug = DEBUG_DEVELOPER et le cache désactivé pour certaines couches, on peut se croire à l’abri — vous ne l’êtes pas pour celui-là. Symptôme classique : « mon observer n’est jamais appelé » alors que le code est parfait.
Événements abstraits et wildcard : la valeur eventname peut être une classe d’event abstraite du core — vous recevez alors tous les events concrets qui en héritent (par exemple observer \core\event\course_module_viewed capte les course_module_viewed de tous les modules). Et '*' capte absolument tout : c’est ainsi que fonctionnent les log stores.
5. Logs et analytics
La boucle se referme ici : le plugin logstore_standard (sous-plugin de tool_log) est… un observer wildcard. Chaque event déclenché est sérialisé et inséré dans la table mdl_logstore_standard_log (par lots, bufferisés en fin de requête). Les colonnes de cette table sont exactement les propriétés standard des events : eventname, component, action, objecttable, objectid, crud, edulevel, contextid, userid, relateduserid, courseid, other (JSON), timecreated…
Pour lire les logs par programmation, ne requêtez pas la table en dur : passez par le log manager, qui abstrait le store réellement actif (standard, base de données externe, etc.) :
<?php
// Lecture des logs via l'API (indépendant du store configuré).
$manager = get_log_manager();
// On demande les readers capables de répondre à des requêtes SQL.
/** @var \core\log\sql_reader[] $readers */
$readers = $manager->get_readers('\core\log\sql_reader');
if (empty($readers)) {
throw new \moodle_exception('nologreaderenabled', 'local_crmsync');
}
$reader = reset($readers);
// Les events "recipe_published" des dernières 24 h dans un cours.
$select = "eventname = :eventname AND courseid = :courseid AND timecreated > :since";
$params = [
'eventname' => '\mod_recette\event\recipe_published',
'courseid' => $courseid,
'since' => time() - DAYSECS,
];
// get_events_select() retourne des OBJETS event reconstruits
// (\core\event\base::restore) — pas des lignes SQL brutes.
$events = $reader->get_events_select($select, $params, 'timecreated DESC', 0, 100);
foreach ($events as $event) {
mtrace($event->get_description());
}Au-delà des logs bruts, les events alimentent :
- les rapports : « Logs » et « Logs en direct » d’un cours, rapport d’activité, rapport de participation (qui s’appuie précisément sur
crudetedulevelpour distinguer « consultations » et « contributions ») ; - l’API analytics (
\core_analytics) : les indicateurs des modèles prédictifs (risque d’abandon…) sont pour la plupart calculés à partir des events loggés — c’est pourquoi un module qui ne déclenche pas ses eventscourse_module_viewedest invisible pour l’analytics ; - la complétion et les restrictions d’accès, indirectement, via les observers du core.
📚 Aller plus loin : la référence complète de l’API Events (propriétés, mappings de restauration, tests des events avec
redirectEvents()en PHPUnit) est sur moodledev.io/docs/apis/core/event , et la liste de tous les events du core se consulte directement dans votre instance : Administration du site → Rapports → « Liste des événements » (report_eventlist), qui documente chaque event avec ses métadonnées. Réflexe à prendre avant d’en créer un nouveau.
Partie B — Hooks (4.4+)
6. Pourquoi les hooks
Pendant quinze ans, la réponse de Moodle à « comment un plugin peut-il influencer le comportement du core ? » a été les legacy callbacks : des fonctions globales aux noms magiques dans le lib.php des plugins. Le core faisait :
// L'ancien monde : le core cherche par introspection toutes les
// fonctions nommées {frankenstyle}_before_footer et les appelle.
$pluginswithfunction = get_plugins_with_function('before_footer', 'lib.php');
foreach ($pluginswithfunction as $plugins) {
foreach ($plugins as $function) {
$function();
}
}Les défauts de ce mécanisme sont ceux de toute API fondée sur des conventions de nommage non typées : aucune signature vérifiable (les arguments changent silencieusement entre versions), aucune découvrabilité (comment savoir quels callbacks existent ? en grepant le core…), chargement de lib.php entiers (des centaines de fonctions) pour appeler une seule fonction, impossibilité de prioriser proprement, et pas de dépréciation outillée.
Moodle 4.4 (avril 2024) a introduit la Hooks API pour y remédier, avec une adoption progressive : à chaque version majeure (4.5, 5.0, 5.1, 5.2), des lots de legacy callbacks sont convertis en hooks et dépréciés. En 5.2, les hooks sont le mécanisme d’extension standard ; les legacy callbacks survivants sont en sursis documenté.
Le design s’inspire directement de PSR-14 (Event Dispatcher) : techniquement, un hook est un event PSR-14 — un objet dispatché à des listeners. Mais là où les events Moodle (partie A) sont immuables et tournés vers le passé, le hook est un objet mutable dispatché pendant l’action : les callbacks le modifient (ajoutent du HTML, changent une valeur, votent…), et le code appelant relit l’objet après dispatch pour en tenir compte. La sémantique « event » de PSR-14 est donc ici mise au service d’un mécanisme d’extension, pas de journalisation. Les deux API coexistent sans se chevaucher.
💡 Pour un dev React : les hooks Moodle n’ont rien à voir avec les hooks React (
useState…). L’analogie exacte, c’est le système de plugins de webpack/Vite/Rollup. Dans webpack, le core expose des points nommés et typés (compiler.hooks.emit.tapAsync(...)) ; chaque plugin s’y branche, reçoit un objet mutable (la compilation), le modifie, et le core poursuit avec l’objet enrichi. Priorités, exécution ordonnée, possibilité de court-circuiter (bailHooks↔ hooks stoppables) : la correspondance est terme à terme.db/hooks.phpest votrewebpack.config.js > plugins, la classe hook est l’objetcompilation.
7. Consommer un hook
Consommer (écouter) un hook existant demande deux pièces : un fichier de registre db/hooks.php et une classe de callbacks. Exemple réaliste : un plugin local qui injecte un script de consentement cookies dans le <head> et une bannière dans le footer de toutes les pages.
1) Le registre — attention, la variable s’appelle $callbacks (pas $hooks) :
<?php
// Fichier : local_cookieconsent/db/hooks.php
defined('MOODLE_INTERNAL') || die();
$callbacks = [
[
// La classe du hook écouté, en notation ::class (typé, refactorable,
// vérifiable par l'IDE — tout ce que les legacy callbacks n'étaient pas).
'hook' => \core\hook\output\before_standard_head_html_generation::class,
// Le callback : notation tableau [classe, méthode] (recommandée)
// ou chaîne 'ns\classe::méthode'. Méthode statique publique.
'callback' => [
\local_cookieconsent\hook_callbacks::class,
'before_standard_head_html_generation',
],
// Priorité : plus élevée = appelé plus tôt. Défaut : 100.
'priority' => 100,
],
[
'hook' => \core\hook\output\before_footer_html_generation::class,
'callback' => [
\local_cookieconsent\hook_callbacks::class,
'before_footer_html_generation',
],
],
];2) La classe de callbacks — par convention hook_callbacks (ou plusieurs classes sous classes/local/hook/) :
<?php
// Fichier : local_cookieconsent/classes/hook_callbacks.php
namespace local_cookieconsent;
use core\hook\output\before_standard_head_html_generation;
use core\hook\output\before_footer_html_generation;
/**
* Callbacks de hooks du plugin. Méthodes statiques, un paramètre :
* le hook typé. Le typage fort est tout l'intérêt : la signature
* du hook est un contrat, plus une convention orale.
*/
class hook_callbacks {
public static function before_standard_head_html_generation(
before_standard_head_html_generation $hook,
): void {
// Bonne pratique : sortir tôt pendant l'installation/upgrade,
// où la config n'est pas fiable.
if (during_initial_install() || isset($CFG->upgraderunning)) {
return;
}
// Le hook expose des méthodes MUTATRICES : on ne retourne rien,
// on enrichit l'objet. Ici, add_html() accumule du HTML qui
// sera inséré dans le <head> de la page.
$hook->add_html(
'<script defer src="' . (new \moodle_url('/local/cookieconsent/js/consent.js'))->out(false) . '"></script>'
);
}
public static function before_footer_html_generation(
before_footer_html_generation $hook,
): void {
global $OUTPUT;
// On peut rendre un template mustache comme n'importe où ailleurs.
$hook->add_html(
$OUTPUT->render_from_template('local_cookieconsent/banner', [
'policyurl' => (new \moodle_url('/local/cookieconsent/policy.php'))->out(false),
])
);
}
}Hooks core courants que vous consommerez le plus souvent (namespace \core\hook\ sauf mention) :
| Hook | Moment | Usage typique |
|---|---|---|
output\before_standard_head_html_generation | Génération du <head> | Balises meta, scripts analytics, CSS |
output\before_standard_top_of_body_html_generation | Juste après <body> | Snippets type GTM <noscript> |
output\before_footer_html_generation | Avant le footer | Bannières, scripts de fin de page |
output\before_standard_footer_html_generation | HTML du footer standard | Liens légaux, mentions |
output\before_http_headers | Avant l’envoi des en-têtes HTTP | En-têtes custom (CSP…) |
after_config | Juste après le chargement de config.php/setup | Init très précoce (feature flags) — dangereux, tout n’est pas encore chargé |
navigation\* (ex. navigation\primary_extend) | Construction des menus | Ajouter des entrées de navigation |
before_course_deleted (5.x) | Avant suppression d’un cours | Nettoyage préventif dans des systèmes externes |
Le hook manager et la page d’administration. L’orchestrateur est \core\hook\manager : il agrège les db/hooks.php de tous les plugins, trie par priorité et dispatche. Deux outils précieux :
- Administration du site → Développement → « Hooks overview » (
admin/hooks.php) : liste tous les hooks connus de l’instance, avec leur description (l’attributlabel), et pour chacun les callbacks enregistrés, leur ordre et leur état. C’est LA page à consulter pour découvrir les points d’extension disponibles — l’équivalent de la doc auto-générée des hooks webpack. - La possibilité pour l’admin de désactiver ou reprioriser un callback via
config.php, sans toucher au code du plugin :
// Dans config.php — neutraliser le callback d'un plugin problématique :
$CFG->hooks_callback_overrides = [
\core\hook\output\before_footer_html_generation::class => [
'local_cookieconsent\hook_callbacks::before_footer_html_generation' => [
'disabled' => true,
],
],
];⚠️ Piège : exactement comme
db/events.php, le contenu dedb/hooks.phpest mis en cache. Ajout, retrait ou changement de priorité d’un callback → bump deversion.php+ upgrade, ou purge des caches. Deuxième piège dans le piège : si votre callback ne se déclenche toujours pas après purge, vérifiez le nom de la variable ($callbacks, pas$hooksni$observers— l’erreur est silencieuse) et que la méthode est bienpublic static.
8. Définir son propre hook
Le vrai changement de culture, c’est que vos plugins aussi peuvent exposer des hooks — offrir aux autres plugins un point d’extension propre, là où avant on inventait des callbacks maison. Cas concret de bout en bout : mod_recette veut permettre à des plugins tiers d’ajouter des badges (« végétarien », « sans gluten », fournis par d’autres plugins) sur la page d’une recette, et éventuellement de bloquer la publication.
1) La classe du hook — dans classes/hook/ du composant qui le possède :
<?php
// Fichier : mod/recette/classes/hook/recipe_badges_requested.php
namespace mod_recette\hook;
use core\attribute;
/**
* Hook dispatché au rendu d'une recette pour collecter des badges
* fournis par d'autres plugins.
*
* Bonnes pratiques appliquées ici :
* - classe FINAL : le hook est un contrat, pas une base d'héritage ;
* - données d'entrée en readonly : les callbacks lisent le contexte
* mais ne peuvent pas le falsifier ;
* - mutabilité UNIQUEMENT via des méthodes add_xxx contrôlées ;
* - attributs de description pour la page « Hooks overview ».
*/
#[attribute\label('Collects display badges for a recipe from any plugin.')]
#[attribute\tags('mod_recette', 'output')]
final class recipe_badges_requested {
/** @var array<int, array{text: string, class: string}> Badges collectés. */
private array $badges = [];
public function __construct(
/** La recette concernée (lecture seule pour les callbacks). */
public readonly \stdClass $recipe,
/** Le contexte du module. */
public readonly \context_module $context,
) {
}
/**
* API mutatrice offerte aux callbacks : c'est par ELLE, et elle
* seule, que le hook se remplit. On valide à l'entrée.
*/
public function add_badge(string $text, string $cssclass = 'badge-secondary'): void {
if (trim($text) === '') {
throw new \coding_exception('Badge text cannot be empty.');
}
$this->badges[] = ['text' => $text, 'class' => $cssclass];
}
/** Lecture du résultat par le code appelant, après dispatch. */
public function get_badges(): array {
return $this->badges;
}
}Pour un hook stoppable (un callback peut interrompre la chaîne — utile pour les hooks « de veto »), on implémente l’interface PSR-14 avec le trait fourni :
<?php
// Fichier : mod/recette/classes/hook/before_recipe_published.php
namespace mod_recette\hook;
use core\attribute;
#[attribute\label('Allows plugins to veto the publication of a recipe.')]
#[attribute\tags('mod_recette')]
final class before_recipe_published implements \Psr\EventDispatcher\StoppableEventInterface {
// Le trait fournit isPropagationStopped() et stop().
use \core\hook\stoppable_trait;
private ?string $vetoreason = null;
public function __construct(
public readonly \stdClass $recipe,
) {
}
/** Un callback pose son veto : on mémorise et on stoppe la chaîne. */
public function veto(string $reason): void {
$this->vetoreason = $reason;
$this->stop(); // les callbacks suivants ne seront pas appelés
}
public function get_veto_reason(): ?string {
return $this->vetoreason;
}
}2) Dispatcher le hook. La forme recommandée depuis 4.4 passe par le conteneur d’injection de dépendances (\core\di, lui aussi introduit en 4.4) — c’est ce que documente moodledev.io, car cela permet de substituer le manager dans les tests :
<?php
// Dans le code de rendu de mod/recette :
$hook = new \mod_recette\hook\recipe_badges_requested($recipe, $context);
// Forme RECOMMANDÉE : via le conteneur DI. En test PHPUnit, on peut
// remplacer le manager (\core\di::set(...)) ou utiliser
// \core\hook\manager::phpunit_get_instance([...]) avec des fixtures.
\core\di::get(\core\hook\manager::class)->dispatch($hook);
// Forme historique, équivalente en prod mais moins testable :
// \core\hook\manager::get_instance()->dispatch($hook);
// APRÈS le dispatch, on relit l'objet muté : c'est ça, un hook.
foreach ($hook->get_badges() as $badge) {
echo \html_writer::span(s($badge['text']), 'badge ' . s($badge['class']));
}Et pour le hook de veto :
<?php
$hook = new \mod_recette\hook\before_recipe_published($recipe);
\core\di::get(\core\hook\manager::class)->dispatch($hook);
if ($reason = $hook->get_veto_reason()) {
// Un plugin a bloqué la publication : on s'arrête proprement.
throw new \moodle_exception('publicationvetoed', 'mod_recette', '', $reason);
}
// Sinon : publier, PUIS déclencher l'event recipe_published (partie A).
// Notez la complémentarité : hook AVANT (influençable), event APRÈS (constat).3) Côté consommateur, un plugin tiers local_dietinfo n’a plus qu’à faire ce qu’on a vu en section 7 :
<?php
// local/dietinfo/db/hooks.php
$callbacks = [
[
'hook' => \mod_recette\hook\recipe_badges_requested::class,
'callback' => [\local_dietinfo\hook_callbacks::class, 'add_diet_badges'],
'priority' => 200, // avant les callbacks à 100
],
];<?php
// local/dietinfo/classes/hook_callbacks.php
namespace local_dietinfo;
class hook_callbacks {
public static function add_diet_badges(
\mod_recette\hook\recipe_badges_requested $hook,
): void {
// Lecture du contexte readonly, mutation via l'API du hook.
if (self::is_vegetarian($hook->recipe)) {
$hook->add_badge(get_string('vegetarian', 'local_dietinfo'), 'badge-success');
}
}
private static function is_vegetarian(\stdClass $recipe): bool {
// ... logique métier ...
return true;
}
}Remarquez le découplage total : mod_recette ne connaît pas local_dietinfo, et local_dietinfo ne dépend que de la classe de hook publique de mod_recette. Si mod_recette n’est pas installé, le callback n’est simplement jamais résolu.
💡 Pour un dev React : la paire « propriétés
readonly+ méthodesadd_xxx» est l’équivalent PHP d’un reducer avec état encapsulé : les callbacks ne peuvent pas fairehook.badges = whatever(mutation sauvage), ils passent par une action validée (add_badge). Même philosophie que les hookstapablede webpack qui passent un objet d’API plutôt que des structures nues, ou qu’Immer qui canalise les mutations. Concevez toujours vos hooks ainsi : entrées immuables, sorties par méthodes contrôlées. Un hook avec des props publiques réinscriptibles est un bug d’API en devenir.
📚 Aller plus loin : la page de référence moodledev.io/docs/5.2/apis/core/hooks couvre aussi ce qu’on n’a fait qu’effleurer : l’interface
\core\hook\described_hook(alternative aux attributs pour décrire un hook), l’interface\core\hook\deprecated_callback_replacement(un hook y déclare quels legacy callbacks il remplace viaget_deprecated_plugin_callbacks(), ce qui génère automatiquement les avertissements de dépréciation), et le test des hooks avec des callbacks fixtures en PHPUnit. La liste des hooks du core par version est danslib/db/hooks.phpet souslib/classes/hook/.
9. Legacy callbacks encore courants
La migration vers les hooks est progressive : en 5.2, un nombre significatif de legacy callbacks restent le mécanisme officiel, faute d’équivalent hook. Il faut donc savoir lire les deux mondes. Panorama des plus importants :
Legacy callback (lib.php du plugin) | Rôle | Statut en 5.2 |
|---|---|---|
{plugin}_extend_settings_navigation($nav, $context) | Ajouter des entrées au menu réglages d’une page | Actif (migration navigation en cours) |
{plugin}_extend_navigation_course($nav, $course, $context) | Ajouter des entrées à la navigation d’un cours | Actif |
{plugin}_pluginfile($course, $cm, $context, $filearea, $args, $forcedownload, $options) | Servir les fichiers du plugin (voir chap. 05) | Actif, aucun remplacement prévu à court terme |
{mod}_get_coursemodule_info($coursemodule) → cached_cm_info | Infos affichées sur la page de cours (nom dynamique, contenu inline…) | Actif |
{mod}_add_instance($data, $mform) / _update_instance / _delete_instance | Cycle de vie CRUD d’une instance de module | Actif — cœur du contrat des modules d’activité |
{mod}_supports($feature) avec les constantes FEATURE_* | Déclarer les capacités du module (FEATURE_GRADE_HAS_GRADE, FEATURE_COMPLETION_TRACKS_VIEWS, FEATURE_MOD_PURPOSE…) | Actif |
{plugin}_cron() | Ancien cron par plugin | Mort depuis longtemps — remplacé par la Task API (partie C) |
{plugin}_before_footer(), _before_standard_html_head(), _before_http_headers(), _add_htmlarea_element()… | Injection HTML / en-têtes | Dépréciés depuis 4.4, remplacés par les hooks \core\hook\output\* vus en section 7 ; en 5.x leur appel émet un avertissement puis disparaît selon le calendrier de dépréciation |
{mod}_get_shortcuts($defaultitem) | Variantes dans le sélecteur d’activités | Déprécié (remplacé côté core par get_course_content_items) |
{plugin}_output_fragment_{name}($args) | Rendu de fragments AJAX (Fragment API) | Actif |
{plugin}_myprofile_navigation(...), {plugin}_control_view_profile(...) | Page de profil | Actif |
Deux réflexes pour savoir si un callback a un équivalent hook :
- La page « Hooks overview » de votre instance (Administration du site → Développement) et le fichier
lib/db/hooks.phpdu core : si un hook y déclare remplacer votre callback (viadeprecated_callback_replacement), migrez. - Le fichier
lib/upgrade.txtet les notes de version développeur sur moodledev.io, qui listent à chaque version les callbacks convertis.
⚠️ Piège : si votre plugin définit à la fois le legacy callback (ex.
local_foo_before_footer()) et le hook équivalent dansdb/hooks.php, seul le hook est exécuté dès lors que le legacy est marqué remplacé — mais sur les versions antérieures à 4.4, seul le legacy existe. Les plugins qui visent une compatibilité multi-versions (4.1 LTS → 5.2) doivent donc conserver les deux pendant la période de transition, avec le legacy réduit à un simple garde (if (!\core\hook\manager::get_instance()->is_deprecated_plugin_callback('before_footer')) { ... }ou détection de version). Consultez le code de plugins majeurs (ex. les plugins Catalyst/Moodle HQ) pour voir le pattern canonique.
Partie C — Task API
La Task API est le remplaçant unifié de l’antique {plugin}_cron(). Deux familles : les scheduled tasks (récurrentes, planifiées, configurables par l’admin) et les adhoc tasks (ponctuelles, mises en file à la demande, avec payload). Toutes deux sont exécutées par le cron Moodle : en production, php admin/cli/cron.php doit tourner toutes les minutes (cron système, supervisor, ou conteneur dédié) ; ce runner unique distribue ensuite le travail.
💡 Pour un dev React : la correspondance avec l’écosystème Node est directe. Scheduled task ↔ cron job / Vercel Cron (
vercel.json > crons) : récurrent, planifié déclarativement, sans payload. Adhoc task ↔ job BullMQ/Sidekiq : mis en file avec un payload JSON, exécuté par un worker asynchrone, retry avec backoff exponentiel en cas d’échec, option de déduplication. Le cron Moodle joue le rôle du worker process ;db/tasks.phpest votre définition déclarative de crons ;queue_adhoc_task()est votrequeue.add(name, data).
10. Scheduled tasks
Une scheduled task est une classe dans classes/task/ qui étend \core\task\scheduled_task, plus une entrée déclarative dans db/tasks.php.
<?php
// Fichier : local/crmsync/classes/task/cleanup_task.php
namespace local_crmsync\task;
/**
* Tâche planifiée : purge les enregistrements de synchronisation
* CRM de plus de 90 jours.
*/
class cleanup_task extends \core\task\scheduled_task {
/**
* Nom affiché dans l'admin (Administration du site → Serveur →
* Tâches → Tâches planifiées). Toujours via get_string().
*/
public function get_name(): string {
return get_string('cleanuptask', 'local_crmsync');
}
/**
* Le travail lui-même. Contrat :
* - mtrace() pour tracer (visible en CLI et dans les logs de tâches) ;
* - terminer normalement = succès ;
* - lever une exception = ÉCHEC → la tâche sera considérée en
* erreur et re-tentée à sa prochaine occurrence planifiée
* (avec faildelay croissant si les échecs se répètent).
*/
public function execute(): void {
global $DB;
$cutoff = time() - (90 * DAYSECS);
mtrace('Purging CRM sync records older than ' . userdate($cutoff) . '...');
$count = $DB->count_records_select('local_crmsync_log', 'timecreated < ?', [$cutoff]);
$DB->delete_records_select('local_crmsync_log', 'timecreated < ?', [$cutoff]);
mtrace("Done. {$count} records purged.");
// Pas de return : void. Le succès, c'est l'absence d'exception.
}
}La déclaration avec sa planification par défaut (l’admin peut la modifier dans l’interface, votre valeur n’est que la suggestion initiale) :
<?php
// Fichier : local/crmsync/db/tasks.php
defined('MOODLE_INTERNAL') || die();
$tasks = [
[
// Classe SANS backslash initial ici (convention de ce fichier).
'classname' => 'local_crmsync\task\cleanup_task',
// blocking = 1 empêcherait toute autre tâche de tourner en même
// temps. À réserver à des cas exceptionnels ; déprécié en pratique.
'blocking' => 0,
// Syntaxe cron classique. Ici : tous les jours à 02:30.
'minute' => '30',
'hour' => '2',
'day' => '*',
'month' => '*',
'dayofweek' => '*',
// Optionnel : 'disabled' => 1 pour livrer la tâche désactivée.
],
];Comme toujours avec les fichiers db/, un bump de version + upgrade (ou purge de caches) est nécessaire pour que la tâche soit enregistrée dans la table task_scheduled.
Exécution et débogage. En prod, le cron s’en charge. En dev, deux commandes CLI indispensables :
# Lister toutes les tâches planifiées, leur prochaine exécution, leur état :
php admin/cli/scheduled_task.php --list
# Exécuter UNE tâche précise, immédiatement, avec sa sortie mtrace :
php admin/cli/scheduled_task.php --execute='\local_crmsync\task\cleanup_task'
# Forcer même si désactivée / hors planning :
php admin/cli/scheduled_task.php --execute='\local_crmsync\task\cleanup_task' --force
# Et le cron complet (tout ce qui est dû) :
php admin/cli/cron.phpCôté gestion d’erreurs : toute exception non attrapée dans execute() marque la tâche en échec. Moodle mémorise un faildelay qui double à chaque échec consécutif (1 min, 2, 4, … plafonné à 24 h) : une tâche qui plante en boucle s’espace automatiquement au lieu de marteler le système. L’admin voit l’état (dernier résultat, durée, prochaine exécution) dans Administration du site → Serveur → Tâches, et les logs détaillés de chaque exécution dans « Journaux des tâches » (task_log).
⚠️ Piège :
mtrace()écrit sur la sortie standard, pas dans les logs Moodle classiques. En prod, la sortie du cron doit être capturée (redirection du cron système, ou les task logs intégrés qui l’enregistrent). Autre piège de taille :execute()s’exécute en contexte CLI, sans utilisateur connecté ($USERest l’utilisateur système). Tout code qui suppose un$USERréel, une session, ouhas_capability()implicite sur l’utilisateur courant se comportera différemment. Si la tâche agit « au nom » d’un utilisateur, utilisez les API qui prennent un userid explicite — ou\core\cron::setup_user($user)en connaissance de cause.
11. Adhoc tasks
L’adhoc task est la file de jobs de Moodle : on la crée à la demande, avec un payload, et le cron l’exécutera dès que possible (les workers cron traitent la file adhoc en continu, en parallèle si configuré). Cas d’usage canoniques : envois massifs de mails, recalculs lourds déclenchés par une action utilisateur (on répond à l’utilisateur tout de suite, on travaille ensuite), suppressions en cascade, appels d’API externes depuis un observer (comme dans notre section 4).
Exemple complet — la tâche de push CRM mise en file par notre observer :
<?php
// Fichier : local/crmsync/classes/task/push_user_task.php
namespace local_crmsync\task;
/**
* Tâche adhoc : pousse un utilisateur vers le CRM externe.
* Une instance = un job, avec son payload propre.
*/
class push_user_task extends \core\task\adhoc_task {
public function execute(): void {
// get_custom_data() restitue le payload posé au moment du
// queue_adhoc_task(). ATTENTION : il a fait l'aller-retour
// par JSON (stocké en base) — vous récupérez un stdClass,
// pas votre tableau d'origine, et jamais d'objets métier.
$data = $this->get_custom_data();
$userid = (int) $data->userid;
$action = (string) $data->action;
mtrace("Pushing user {$userid} to CRM (action: {$action})...");
$user = \core_user::get_user($userid);
if (!$user || $user->deleted) {
// Cas attendu (l'utilisateur a pu être supprimé entre la mise
// en file et l'exécution) : on sort PROPREMENT, sans exception,
// sinon la tâche serait re-tentée pour rien à l'infini.
mtrace('User no longer exists, skipping.');
return;
}
$client = new \local_crmsync\crm_client();
// Si l'appel HTTP échoue, crm_client lève une exception → la
// tâche échoue → Moodle la RE-TENTERA automatiquement avec un
// backoff croissant (1 min, 2, 4... jusqu'à 24 h), indéfiniment
// tant qu'elle échoue. C'est le comportement voulu pour une
// erreur transitoire (CRM down) ; pour une erreur définitive,
// attrapez-la et sortez proprement.
$client->upsert_contact($user, $action);
mtrace('Done.');
}
}Et la mise en file, revue en détail :
<?php
// N'importe où (observer, contrôleur de formulaire, web service...) :
$task = new \local_crmsync\task\push_user_task();
// Payload : UNIQUEMENT du JSON-sérialisable (scalaires, tableaux,
// stdClass). Passez des IDS, jamais des objets métier — l'objet
// pourrait être périmé au moment de l'exécution, des heures plus tard.
$task->set_custom_data([
'userid' => $user->id,
'action' => 'create',
]);
// Optionnel : exécuter la tâche "en tant que" cet utilisateur
// ($USER sera configuré pour lui pendant execute()).
// $task->set_userid($user->id);
// Mise en file. Le second paramètre $checkforexisting = true active
// la DÉDUPLICATION : si une tâche identique (même classe, même
// custom_data, même userid) est déjà en attente, on n'en rajoute pas.
// Retourne l'id de la tâche en file, ou false si dédupliquée.
\core\task\manager::queue_adhoc_task($task, true);
// Variante : différer l'exécution (pas avant tel timestamp) :
// $task->set_next_run_time(time() + HOURSECS);
// \core\task\manager::queue_adhoc_task($task);Débogage CLI des adhoc tasks :
# Exécuter toutes les adhoc tasks en attente :
php admin/cli/adhoc_task.php --execute
# N'exécuter que celles d'une classe donnée :
php admin/cli/adhoc_task.php --classname='\local_crmsync\task\push_user_task' --execute
# Exécuter une tâche précise par son id (table {task_adhoc}) :
php admin/cli/adhoc_task.php --id=1234 --execute
# Ne pas oublier : l'état de la file est visible dans l'admin
# (Serveur → Tâches → "Tâches ad hoc") et dans la table {task_adhoc}
# (colonnes faildelay, nextruntime très utiles pour diagnostiquer).⚠️ Piège : le retry infini. Contrairement à BullMQ où l’on configure
attempts: 5, une adhoc task Moodle qui lève une exception est re-tentée indéfiniment (avec backoff plafonné à 24 h). Une tâche qui échoue de façon permanente (donnée corrompue, utilisateur supprimé…) restera pour toujours dans la file, réessayant chaque jour. D’où la règle : distinguez dansexecute()les erreurs transitoires (laissez remonter l’exception → retry) des erreurs définitives (loggez avecmtrace, sortez avecreturn→ succès, tâche consommée). Depuis les versions récentes, l’admin peut aussi supprimer manuellement une adhoc task bloquée depuis l’interface — mais ne comptez pas sur lui.
12. Verrous et concurrence
Dès que le cron tourne toutes les minutes, potentiellement avec plusieurs workers en parallèle ($CFG->task_scheduled_concurrency_limit, admin/cli/adhoc_task.php lancé en plusieurs exemplaires…), la question de la concurrence se pose. Moodle fournit une Lock API générique, celle-là même qui empêche déjà deux crons de traiter la même tâche (le fameux cron lock : chaque scheduled task est exécutée sous verrou, une instance à la fois, dans tout le cluster).
Pour vos propres sections critiques (ex. : garantir qu’un seul import CRM tourne à la fois, même si deux adhoc tasks différentes s’en approchent) :
<?php
// Obtenir une factory de verrous pour votre composant. Le type de
// verrou effectif (fichiers, DB, Redis...) dépend de la config du
// site ($CFG->lock_factory) — votre code n'a pas à le savoir.
$factory = \core\lock\lock_config::get_lock_factory('local_crmsync');
// Tenter d'acquérir le verrou 'fullimport' en attendant au max 10 s.
// Le 3e paramètre (max lifetime) est un garde-fou : au-delà, le verrou
// est considéré comme orphelin (processus mort) et peut être volé.
$lock = $factory->get_lock('fullimport', 10, 600);
if (!$lock) {
// Quelqu'un d'autre importe déjà : on abandonne SANS erreur.
mtrace('Another import is already running, skipping.');
return;
}
try {
// ... section critique : l'import lui-même ...
} finally {
// TOUJOURS relâcher dans un finally : une exception dans la
// section critique ne doit jamais laisser un verrou pendant.
$lock->release();
}Bonnes pratiques express : des clés de verrou fines (par cours, par utilisateur : 'import_user_' . $userid) plutôt qu’un verrou global ; des timeouts courts à l’acquisition (échouer vite et laisser le retry adhoc faire son œuvre vaut mieux que bloquer un worker) ; et rappelez-vous que les scheduled tasks sont déjà mutuellement exclues avec elles-mêmes — inutile d’y ajouter un verrou « contre soi-même », le besoin ne naît que quand plusieurs chemins de code différents touchent la même ressource.
📚 Aller plus loin : la référence de la Task API — y compris les sujets avancés non couverts ici : progression (
\core\task\manager+ barres de progression), tâches avec échec définitif, montée en parallèle du cron,$CFG->cron_keepalive— est sur moodledev.io/docs/5.2/apis/subsystems/task , et la Lock API sur moodledev.io/docs/5.2/apis/core/lock . Lisez aussiadmin/cli/cron.phplui-même : c’est court et très instructif sur le cycle réel d’exécution.
Partie D — Message API
13. Message providers et envoi
La Message API répond à un problème que tout dev ayant écrit sendEmail() en dur connaît : l’utilisateur veut choisir comment être notifié (web, email, push mobile), par type de message, et l’admin veut des défauts raisonnables. Moodle sépare donc :
- le provider : la catégorie de message qu’un plugin peut émettre (déclarée dans
db/messages.php) ; - les processors : les canaux de sortie (plugins
message_:popup— la cloche de notification web —,email,airnotifierpour le mobile…) ; - les préférences : le croisement des deux, réglable par l’utilisateur (Préférences → Préférences de notification) dans les limites fixées par l’admin.
1) Déclarer le provider :
<?php
// Fichier : local/crmsync/db/messages.php
defined('MOODLE_INTERNAL') || die();
$messageproviders = [
// La clé est le 'name' du provider — vous la réutiliserez à l'envoi.
'syncfailed' => [
// Optionnel : seuls les utilisateurs ayant cette capability
// dans un contexte pourront recevoir ce type de message
// (et le voir dans leurs préférences).
'capability' => 'local/crmsync:receivealerts',
// Défauts par processor. Combinaison de constantes :
// MESSAGE_PERMITTED / MESSAGE_FORBIDDEN : l'utilisateur
// peut-il activer/désactiver ce canal ?
// + MESSAGE_DEFAULT_ENABLED : activé par défaut.
'defaults' => [
'popup' => MESSAGE_PERMITTED + MESSAGE_DEFAULT_ENABLED,
'email' => MESSAGE_PERMITTED + MESSAGE_DEFAULT_ENABLED,
// 'airnotifier' => MESSAGE_PERMITTED, // permis, désactivé par défaut
],
],
];N’oubliez pas la chaîne de langue associée, affichée dans l’écran de préférences : $string['messageprovider:syncfailed'] = 'Alertes d\'échec de synchronisation CRM'; dans lang/en/local_crmsync.php (et lang/fr/). Et, rituel désormais familier : bump de version + upgrade pour enregistrer le provider.
2) Envoyer — l’objet \core\message\message puis message_send() :
<?php
// Envoi d'une notification complète. Typiquement depuis une tâche
// (jamais dans le chemin critique d'une page si évitable : l'envoi
// email peut être lent selon la config SMTP).
$recipient = \core_user::get_user($adminid);
$message = new \core\message\message();
// -- Identité du message ------------------------------------------------
$message->component = 'local_crmsync'; // frankenstyle du plugin
$message->name = 'syncfailed'; // clé du provider déclaré
// -- Expéditeur / destinataire -------------------------------------------
// Pour une notification "système", utilisez l'utilisateur noreply :
$message->userfrom = \core_user::get_noreply_user();
$message->userto = $recipient;
// -- Contenu --------------------------------------------------------------
$message->subject = get_string('syncfailedsubject', 'local_crmsync');
// fullmessage : version texte brut. TOUJOURS la fournir : c'est le
// repli pour l'email texte et certains processors.
$message->fullmessage = get_string('syncfailedbody', 'local_crmsync', $info);
$message->fullmessageformat = FORMAT_PLAIN; // format de fullmessage
// fullmessagehtml : version riche, utilisée par l'email HTML.
$message->fullmessagehtml = '<p>' . get_string('syncfailedbody', 'local_crmsync', $info) . '</p>' .
'<p><a href="' . $reporturl->out(false) . '">' . get_string('viewreport', 'local_crmsync') . '</a></p>';
// smallmessage : version courte (popup web, SMS, push mobile).
$message->smallmessage = get_string('syncfailedsmall', 'local_crmsync');
// -- Nature et contexte -----------------------------------------------------
// notification = 1 : c'est une notification système (cloche), PAS un
// message personnel de la messagerie instantanée. Quasi toujours 1
// pour les messages émis par des plugins.
$message->notification = 1;
// URL de contexte : le lien "voir" attaché à la notification.
$message->contexturl = $reporturl->out(false);
$message->contexturlname = get_string('viewreport', 'local_crmsync');
// Optionnel : rattacher à un cours (requis pour certains processors) :
// $message->courseid = $courseid;
// -- Envoi -------------------------------------------------------------------
// message_send() consulte les préférences du destinataire et route
// vers les processors actifs. Retourne l'id du message enregistré,
// ou false en cas d'échec.
$messageid = message_send($message);
if (!$messageid) {
debugging('Failed to send syncfailed notification to user ' . $recipient->id,
DEBUG_DEVELOPER);
}Ce qui se passe ensuite ne vous regarde plus, et c’est le but : si l’utilisateur a activé « popup », la cloche web s’illumine ; s’il a activé « email », un mail part (immédiatement ou en digest selon ses préférences) ; s’il a l’app mobile et qu’Airnotifier est configuré, une push part. L’admin ajuste les défauts et les interdictions globales dans Administration du site → Messagerie → Paramètres de notification — une grille providers × processors qui reflète exactement vos defaults de db/messages.php tant que personne n’y a touché.
💡 Pour un dev React : pensez à la Message API comme à un service de notifications multi-canal type Novu ou Knock : vous publiez un événement de notification typé (
workflow: 'syncfailed', payload), et la plateforme résout les canaux selon les préférences par utilisateur, gère les templates par canal (in-app court ↔smallmessage, email riche ↔fullmessagehtml) et les digests. La leçon d’architecture est identique : le code métier ne connaît jamais le canal ; il décrit l’information, la plateforme route.
⚠️ Piège :
message_send()exige queusertosoit un objet utilisateur complet (avec les champs de préférences chargés) — passez le résultat de\core_user::get_user()ou$DB->get_record('user', ...), pas un objet bricolé avec juste unid, sous peine d’erreurs subtiles ou de préférences ignorées. Deuxième piège : dans une transaction DB,message_send()ne fait pas d’envoi immédiat mais le diffère au commit (mécanique similaire aux observers non-internal) ; n’en dépendez donc jamais de façon synchrone. Troisième : l’oubli de la chaînemessageprovider:xxxrend l’écran de préférences cryptique ([[messageprovider:syncfailed]]) — c’est l’avertissement le plus fréquent des rapports de validation de plugins.
14. Le sous-système Communication
Depuis Moodle 4.2/4.3, un sous-système distinct de la Message API a fait son apparition : Communication (\core_communication). Son objet n’est pas la notification, mais la communication synchrone de groupe : doter chaque cours d’une « salle » sur une plateforme de discussion externe, provisionnée et synchronisée automatiquement (membres de la salle = inscrits au cours, avatar et nom de salle synchronisés, etc.).
L’architecture est — sans surprise — à base de plugins : le sous-système vit dans communication/, et les fournisseurs sont des sous-plugins sous communication/provider/. Le fournisseur de référence est Matrix (communication/provider/matrix), qui pilote un serveur Synapse/Element via ses API d’administration. L’activation se fait au niveau du site (fonctionnalité longtemps marquée expérimentale, à activer explicitement dans les fonctionnalités avancées selon les versions), puis cours par cours dans les réglages du cours.
Pour le développeur, deux points à retenir à ce stade : d’une part, ne confondez pas les deux API — envoyer une notification à un utilisateur reste du ressort de la Message API de la section 13 ; d’autre part, si votre organisation veut brancher un autre outil de chat (Rocket.Chat, Teams…), le contrat à implémenter est celui des interfaces de communication/classes/ (communication_provider, user_provider, room_chat_provider, room_user_provider…) dans un sous-plugin communication/provider/xxx — le provider Matrix sert de plugin de référence lisible. Le sujet reste périphérique pour la plupart des développements ; sachez qu’il existe, revenez-y le jour où le besoin se présente.
📚 Aller plus loin : la Message API est documentée sur moodledev.io/docs/5.2/apis/core/message (y compris l’écriture d’un message processor custom — nouveau canal de sortie — et les message output dans
db/messages.php), et le sous-système Communication sur moodledev.io/docs/5.2/apis/subsystems/communication .
Résumé — points clés
Choisir la bonne API — la décision en une question chacune :
| Besoin | API | Marqueur |
|---|---|---|
| Constater qu’une chose s’est produite (logs, réactions) | Event | Passé, immuable, fire-and-forget |
| Laisser d’autres plugins influencer une action en cours | Hook | Objet mutable, relu après dispatch |
| Faire un travail récurrent planifié | Scheduled task | db/tasks.php, cron |
| Faire un travail ponctuel, asynchrone, avec payload | Adhoc task | queue_adhoc_task, retry backoff |
| Informer un humain (multi-canal, préférences) | Message | db/messages.php, message_send() |
Events : classe dans classes/event/, nom objet_verbe_passé, init() fixe crud/edulevel/objecttable ; other = scalaires sérialisables uniquement ; ::create([...]) → add_record_snapshot() → ->trigger(), toujours après l’écriture en base. Observers déclarés dans db/events.php ($observers), internal => false pour attendre le commit, wildcard '*' possible ; les logs (logstore_standard_log) et l’analytics en découlent, lecture via get_log_manager().
Hooks (4.4+) : remplaçants typés des legacy callbacks, inspirés PSR-14. Consommer = db/hooks.php avec la variable $callbacks (hook ::class, callback [classe, méthode], priority défaut 100, plus haut = plus tôt) + classe de callbacks statiques typés. Définir = classe final dans classes/hook/, props readonly, mutations via méthodes add_xxx, attributs #[\core\attribute\label] / #[\core\attribute\tags], stoppable via StoppableEventInterface + \core\hook\stoppable_trait. Dispatcher : \core\di::get(\core\hook\manager::class)->dispatch($hook) puis relire l’objet. Découverte : page admin « Hooks overview » et lib/db/hooks.php. Les callbacks output (before_footer…) sont dépréciés depuis 4.4 ; pluginfile, add/update/delete_instance, supports(FEATURE_*), navigation restent des legacy actifs en 5.2.
Tasks : scheduled = classe extends scheduled_task (get_name(), execute(), mtrace()) + db/tasks.php (planning par défaut, modifiable par l’admin) ; adhoc = extends adhoc_task + set_custom_data() (JSON only, passez des ids) + \core\task\manager::queue_adhoc_task($task, $checkforexisting) ; exception = échec = retry infini avec backoff → distinguer erreurs transitoires (throw) et définitives (return). CLI : scheduled_task.php --list/--execute, adhoc_task.php --execute. Concurrence : Lock API (lock_config::get_lock_factory(), get_lock(), release() dans un finally).
Messages : provider dans db/messages.php ($messageproviders, capability optionnelle, defaults par processor) ; envoi via new \core\message\message() (component, name, userfrom noreply, userto complet, subject, fullmessage + format, fullmessagehtml, smallmessage, notification = 1, contexturl) et message_send(). Les préférences utilisateur/admin routent vers popup/email/mobile. Le sous-système Communication (4.2+, provider Matrix dans communication/provider/matrix) couvre le chat de cours — distinct et optionnel.
Le rituel commun : tout changement dans un fichier db/ (events.php, hooks.php, tasks.php, messages.php) exige bump de version.php + upgrade ou purge des caches. La moitié des « ça ne marche pas » de ce chapitre se soignent ainsi.
Navigation : ← 05 — Fichiers, strings, config · 07 — Cache, sessions, sécurité →