Skip to Content
MoodlePartie 6 — Développer des pluginsAnatomie universelle d'un plugin

Chapitre 02 — Anatomie universelle d’un plugin

Dans le chapitre précédent, nous avons dressé le panorama complet des types de plugins Moodle : les modules d’activité (mod), les blocs (block), les thèmes (theme), les plugins locaux (local), les méthodes d’inscription (enrol), les formats de cours (format), et les dizaines d’autres types qui composent l’écosystème. Vous savez désormais ranger votre code selon ce qu’il doit faire.

Ce chapitre change de perspective : au lieu de regarder ce qui différencie les types de plugins, nous allons étudier ce que tous les plugins partagent. Car c’est l’une des grandes forces de l’architecture Moodle : qu’il s’agisse d’un module d’activité sophistiqué ou d’un simple plugin local, la structure interne obéit aux mêmes conventions. Un fichier version.php déclare l’identité du plugin, un dossier lang/ contient ses chaînes de caractères, un dossier db/ décrit ses interactions avec le cœur (tables, permissions, hooks, tâches planifiées…), un dossier classes/ héberge son code autochargé.

Une fois cette anatomie assimilée, vous saurez ouvrir n’importe quel plugin — le vôtre ou celui d’un tiers — et comprendre en quelques secondes ce qu’il fait, où il s’accroche au cœur, et comment il est installé, mis à jour et désinstallé. C’est exactement le réflexe qu’a un développeur React quand il ouvre un projet et regarde d’abord package.json, puis la structure de src/.

💡 Pour un dev React : pensez à ce chapitre comme à la description du « contrat de package ». Dans l’écosystème npm, un package est défini par son package.json (nom, version, dépendances, points d’entrée). Dans Moodle, ce contrat est éclaté en plusieurs fichiers déclaratifs : version.php joue le rôle de package.json, le dossier db/ celui des « manifestes » d’intégration (un peu comme les champs exports, bin ou les hooks de lifecycle npm), et classes/ est votre src/ avec un autoloading conventionnel.

Tout au long de ce chapitre, nous utiliserons un plugin fictif nommé local_monplugin comme fil rouge (et ponctuellement mod_exemple quand une spécificité des modules d’activité mérite d’être signalée). Rappelez-vous que depuis Moodle 5.1, la racine web du projet se trouve dans le dossier public/ : notre plugin vivra donc physiquement dans public/local/monplugin/.


1. Vue d’ensemble : l’arborescence complète d’un plugin

Commençons par la vue satellite. Voici l’arborescence d’un plugin local_monplugin volontairement maximaliste : aucun plugin réel n’a besoin de tous ces fichiers, mais chacun d’eux peut exister et a un rôle précis. Chaque ligne est annotée.

public/local/monplugin/ ├── version.php ← OBLIGATOIRE. Carte d'identité : composant, version, dépendances. ├── lang/ │ ├── en/ │ │ └── local_monplugin.php ← OBLIGATOIRE. Chaînes de langue anglaises (au minimum 'pluginname'). │ └── fr/ │ └── local_monplugin.php ← Optionnel. Traduction française locale (AMOS préféré pour le public). ├── db/ ← Dossier des manifestes déclaratifs lus à l'installation/mise à jour. │ ├── access.php ← Déclaration des capabilities (permissions) du plugin. │ ├── install.xml ← Schéma des tables de base de données (généré par l'éditeur XMLDB). │ ├── install.php ← Code PHP exécuté une seule fois, juste après la création des tables. │ ├── upgrade.php ← Migrations de schéma/données entre versions (savepoints). │ ├── uninstall.php ← Ménage personnalisé exécuté avant la désinstallation automatique. │ ├── hooks.php ← Enregistrement des callbacks de hooks (mécanisme moderne 4.4+/5.x). │ ├── events.php ← Observateurs d'événements (event observers). │ ├── services.php ← Fonctions externes (web services / appels AJAX) et services préconstruits. │ ├── tasks.php ← Tâches planifiées (cron moderne) et leur horaire par défaut. │ ├── messages.php ← Fournisseurs de messages/notifications (message providers). │ ├── caches.php ← Définitions de caches MUC (Moodle Universal Cache). │ ├── mobile.php ← Déclarations pour l'app mobile Moodle (addons CoreCourseModuleDelegate...). │ ├── renamedclasses.php ← Table de correspondance des classes renommées (rétrocompatibilité). │ └── subplugins.json ← Déclaration des types de sous-plugins que ce plugin héberge. ├── classes/ ← Code PHP autochargé (PSR-4-like). LE cœur moderne du plugin. │ ├── privacy/ │ │ └── provider.php ← OBLIGATOIRE pour publication : déclaration RGPD (Privacy API). │ ├── external/ ← Classes de fonctions externes (API web services). │ ├── output/ ← Renderers et renderables (couche de présentation). │ ├── form/ ← Formulaires (moodleform en namespace). │ ├── task/ ← Classes de tâches planifiées et ad hoc. │ ├── event/ ← Classes d'événements émis par le plugin. │ ├── hook_callbacks.php ← Convention : méthodes statiques référencées par db/hooks.php. │ └── local/ ← Code interne « libre » (services métier, helpers...). ├── templates/ ← Templates Mustache (rendu HTML côté serveur ET client). │ └── card.mustache ├── amd/ │ ├── src/ ← Modules JavaScript ES6 (sources). │ │ └── controls.js │ └── build/ ← JS transpilé/minifié par Grunt (jamais édité à la main). ├── pix/ ← Icônes du plugin (icon.svg ; monologo.svg pour les mods). │ └── icon.svg ├── styles.css ← CSS chargé automatiquement... dans la feuille GLOBALE du site. ├── settings.php ← Page de réglages dans l'administration du site. ├── lib.php ← Callbacks legacy découverts par nom de fonction (à minimiser). ├── locallib.php ← (Déconseillé en 5.x) fonctions globales internes — préférez classes/. ├── index.php, view.php... ← Pages web du plugin (points d'entrée HTTP). ├── cli/ ← Scripts en ligne de commande (define('CLI_SCRIPT', true)). ├── backup/ ← Logique de sauvegarde/restauration (surtout pour les mods). ├── tests/ ← Tests PHPUnit (classes *_test.php). │ └── behat/ ← Tests d'acceptation Behat (*.feature). ├── README.md ← Documentation pour l'administrateur. ├── CHANGES.md ← Journal des modifications (importé par le répertoire de plugins). ├── LICENSE ← GPLv3 (obligatoire pour toute distribution). ├── thirdpartylibs.xml ← Déclaration des bibliothèques tierces embarquées. ├── environment.xml ← Exigences d'environnement spécifiques (extensions PHP...). ├── composer.json ← Métadonnées pour l'installation via Composer. └── .github/ └── workflows/ └── ci.yml ← Intégration continue avec moodle-plugin-ci.

Obligatoire, quasi obligatoire, optionnel

Pour ne pas vous noyer, classons ces éléments en trois cercles :

Cercle 1 — Strictement obligatoire (Moodle refuse d’installer le plugin sans eux) :

ÉlémentRôle
version.phpIdentité et version du plugin. Sans lui, le dossier est ignoré ou signalé comme invalide.
lang/en/local_monplugin.phpAu minimum $string['pluginname']. Sans lui, le plugin s’affiche avec un nom cassé [[pluginname]] et la validation échoue.

Cercle 2 — Quasi obligatoire en pratique (techniquement optionnels, mais attendus dans tout plugin sérieux) :

ÉlémentRôle
classes/privacy/provider.phpExigé par la validation du répertoire moodle.org et par toute démarche RGPD sérieuse.
db/access.phpDès que le plugin expose la moindre fonctionnalité à contrôler par permission.
db/install.xml + db/upgrade.phpDès que le plugin possède des tables.
lib.phpObligatoire pour les modules d’activité (mod_*) ; pour les autres types, seulement si des callbacks legacy sont utilisés.
README.md + LICENSEObligatoires pour la distribution publique (GPLv3).

Cercle 3 — Optionnel selon les besoins : tout le reste (settings.php, db/hooks.php, db/tasks.php, templates/, amd/, styles.css, cli/, tests/…). Chacun de ces fichiers n’existe que si le plugin utilise la fonctionnalité correspondante. Moodle les découvre par convention de nommage : il n’y a aucun fichier de configuration central qui les référence.

💡 Pour un dev React : c’est un système de « conventions over configuration » poussé à l’extrême, comparable au routing par système de fichiers de Next.js. De même que placer un fichier app/dashboard/page.tsx crée une route sans aucune déclaration, placer un fichier db/tasks.php dans votre plugin enregistre des tâches cron sans que vous n’importiez quoi que ce soit. Le « framework » scanne les emplacements conventionnels.

📚 Aller plus loin : la référence officielle exhaustive de ces fichiers communs est la page Plugin files  de moodledev.io. Gardez-la en favori : c’est l’une des pages les plus utiles de toute la documentation développeur.


2. version.php en profondeur

C’est le premier fichier que Moodle lit quand il découvre votre plugin, et le seul qui soit lu à chaque comparaison de versions. Il doit être trivialement exécutable : uniquement des affectations de propriétés sur l’objet $plugin, jamais de logique, et surtout jamais de require_once ni d’inclusion de fichier — la documentation officielle est catégorique sur ce point, car ce fichier est chargé dans des contextes très précoces où le reste de l’API n’est pas disponible.

Voici un version.php complet et commenté pour notre fil rouge :

Fichier : local/monplugin/version.php

<?php // This file is part of Moodle - https://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <https://www.gnu.org/licenses/>. /** * Déclaration de version du plugin local_monplugin. * * @package local_monplugin * @copyright 2026 Alex Lemia <alexlemia13@gmail.com> * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); // Identité du plugin : le "frankenstyle" complet, qui DOIT correspondre // au type (dossier parent) et au nom (dossier du plugin). // Ici : public/local/monplugin/ => 'local_monplugin'. $plugin->component = 'local_monplugin'; // Version interne du plugin, au format YYYYMMDDXX. // C'est cette valeur (un entier) que Moodle compare à celle stockée en base. $plugin->version = 2026070800; // Version MINIMALE du cœur Moodle requise. // 2026042000 = Moodle 5.2 (sortie le 20 avril 2026). $plugin->requires = 2026042000; // Plage de branches officiellement supportées (information déclarative, // utilisée par le répertoire de plugins et l'outil de vérification des mises à jour). // [502, 502] = « testé et supporté sur la branche 5.2 uniquement ». $plugin->supported = [502, 502]; // (Optionnel) Première branche du cœur avec laquelle le plugin est INCOMPATIBLE. // Contrairement à 'supported' (informatif), 'incompatible' BLOQUE l'installation. // $plugin->incompatible = 503; // Niveau de maturité de CETTE version du plugin. $plugin->maturity = MATURITY_STABLE; // Version « humaine », en versioning sémantique. C'est elle qu'on affiche // aux administrateurs ; elle n'a aucun rôle dans la logique de mise à jour. $plugin->release = '1.4.0'; // Dépendances envers d'autres plugins : component => version minimale requise, // ou ANY_VERSION si n'importe quelle version convient. $plugin->dependencies = [ 'mod_forum' => 2026042000, 'local_socle' => ANY_VERSION, ];

Décortiquons chaque propriété.

$plugin->component : l’identité frankenstyle

La valeur doit être exactement {type}_{nom}, où le type correspond au dossier parent (local, mod, block…) et le nom au dossier du plugin. Moodle vérifie cette cohérence à l’installation : si vous déposez le code dans public/local/monplugin/ mais que version.php déclare local_autrenom, l’installation est refusée avec une erreur explicite. Cette vérification protège contre l’erreur classique du dossier renommé après un téléchargement (les archives GitHub produisent des dossiers du type moodle-local_monplugin-main qu’il faut renommer en monplugin avant de les déposer).

Le nom du plugin lui-même obéit à des règles strictes : lettres minuscules, chiffres et underscores uniquement, et il doit commencer par une lettre. Pas de tirets, pas de majuscules.

$plugin->version : le format YYYYMMDDXX et sa logique

C’est un entier (pas une chaîne) construit sur la date : 2026070800 se lit « version du 8 juillet 2026, incrément 00 ». Les deux derniers chiffres (XX) permettent jusqu’à 100 versions le même jour, ce qui est utile pendant le développement intensif d’une migration.

Pourquoi ce format plutôt qu’un semver ? Parce que Moodle a besoin d’une comparaison numérique triviale et totale : « la version dans le code est-elle strictement supérieure à celle stockée en base ? ». Un entier datestampé garantit cet ordre total sans parseur, et rend la version auto-documentée (on voit d’un coup d’œil quand elle a été produite).

Les règles d’incrémentation à retenir :

  1. Chaque modification qui doit déclencher quelque chose à la mise à jour (nouvelle table, nouvelle capability, nouveau hook, nouvelle tâche, migration de données) exige d’incrémenter $plugin->version. C’est ce changement de numéro qui fait entrer Moodle en mode « mise à jour » et qui relit les fichiers de db/.
  2. Chaque étape ajoutée dans db/upgrade.php est associée à un numéro de version précis (le savepoint, voir section 4) : le numéro dans version.php doit être supérieur ou égal au dernier savepoint de upgrade.php.
  3. Si vous maintenez plusieurs branches du plugin (une pour Moodle 4.5 LTS, une pour 5.2), la convention officielle est de geler la partie date au jour du fork sur la branche de maintenance et de n’incrémenter que les XX, tandis que la branche principale continue d’avancer avec des dates plus récentes. Ainsi la branche récente a toujours un numéro supérieur, et une montée de version de Moodle entraîne une montée de version du plugin cohérente.

⚠️ Piège : ne diminuez jamais un numéro de version déjà déployé. Moodle détecterait une version en base supérieure à celle du code et refuserait de continuer (« downgrade detected »). Si cela vous arrive en développement (retour en arrière git, par exemple), la sortie propre est de désinstaller/réinstaller le plugin, ou en dernier recours d’ajuster la ligne correspondante dans la table config_plugins — jamais en production.

💡 Pour un dev React : $plugin->version n’est pas votre numéro de version marketing — c’est l’équivalent du numéro de migration dans Prisma ou Rails. $plugin->release (voir plus bas) est le semver humain, $plugin->version est le compteur monotone qui pilote les migrations. Les deux coexistent précisément parce qu’ils servent des usages différents.

$plugin->requires : la version minimale du cœur

C’est le numéro de version du cœur Moodle (le $version de son propre version.php, à la racine) en dessous duquel votre plugin refuse de s’installer. Les valeurs des jalons récents, à connaître :

Version de MoodleDate de sortieNuméro de version
Moodle 4.5 (LTS)octobre 20242024100700
Moodle 5.014 avril 20252025041400
Moodle 5.16 octobre 20252025100600
Moodle 5.220 avril 20262026042000

Pour un plugin ciblant Moodle 5.2, écrivez donc $plugin->requires = 2026042000;. Choisissez cette valeur avec discernement : indiquez la version la plus ancienne réellement testée et fonctionnelle, pas mécaniquement la dernière sortie. Si votre plugin n’utilise aucune API apparue après 5.1, exiger 5.2 prive inutilement les sites en 5.1.

📚 Aller plus loin : la liste complète des numéros de version du cœur, branche par branche, est maintenue sur moodledev.io/general/releases . C’est la source à consulter avant chaque publication.

$plugin->supported : la plage de branches déclarée

Un tableau de deux entiers représentant les numéros de branche (et non les numéros de version longs) : Moodle 5.2 est la branche 502, Moodle 5.1 la branche 501, Moodle 4.5 la branche 405. Ainsi $plugin->supported = [405, 502]; signifie « supporté de 4.5 à 5.2 inclus ». Cette information est purement déclarative : elle n’empêche pas l’installation, mais elle alimente le répertoire de plugins moodle.org et le vérificateur de mises à jour de l’administration, qui peut avertir l’administrateur qu’il installe un plugin hors de sa plage de support.

$plugin->incompatible : le blocage dur

À l’inverse de supported, cette propriété bloque réellement l’installation à partir de la branche indiquée. $plugin->incompatible = 503; interdit l’installation sur Moodle 5.3 et au-delà. Utilisez-la quand vous savez qu’une API dont vous dépendez disparaît dans une version future — c’est rare, mais précieux pour éviter les installations vouées au crash.

$plugin->maturity : les constantes MATURITY_*

Quatre constantes, définies par le cœur, expriment la stabilité de la version :

ConstanteSignification
MATURITY_ALPHAVersion de développement, incomplète, pour tests uniquement.
MATURITY_BETAFonctionnellement complète mais insuffisamment testée.
MATURITY_RCRelease candidate : stable sauf découverte de dernière minute.
MATURITY_STABLEVersion de production.

Leur intérêt pratique : l’administration de Moodle permet de configurer le niveau de maturité minimal des mises à jour notifiées (réglage updateminmaturity). Un site de production configuré sur « stable » ne sera jamais invité à installer votre bêta, même publiée dans le répertoire de plugins. Déclarez donc honnêtement MATURITY_ALPHA ou MATURITY_BETA pendant le développement, et ne passez à MATURITY_STABLE qu’au moment de la vraie publication.

$plugin->release : le versioning humain

Une chaîne libre, par convention en versioning sémantique ('1.4.0'), parfois enrichie d’un qualificatif ('2.0.0-beta.1') ou alignée sur une dépendance amont quand le plugin encapsule une bibliothèque ('v3.11 (basé sur libfoo 2.8)'). Elle est affichée dans la liste des plugins de l’administration et sur moodle.org, et n’intervient dans aucune logique de comparaison : seule $plugin->version compte pour la machine.

$plugin->dependencies : dépendances entre plugins

Un tableau associatif component => version. Moodle refuse d’installer ou de mettre à jour votre plugin si une dépendance est absente ou trop ancienne, et propose à l’administrateur de télécharger les dépendances manquantes depuis le répertoire de plugins quand elles y sont publiées. La constante ANY_VERSION signifie « présent, peu importe la version ». Notez que les dépendances sont vérifiées au moment de l’installation et des mises à jour, et empêchent également la désinstallation d’un plugin dont d’autres dépendent.

Le piège de l’en-tête : GPL et MOODLE_INTERNAL

Deux éléments de l’exemple ci-dessus ne sont pas décoratifs :

  • L’en-tête GPL : tout fichier PHP d’un plugin destiné à la distribution doit porter l’en-tête GPLv3 et le docblock @package / @copyright / @license. Le validateur du répertoire de plugins et l’outil moodle-plugin-ci (via les code sniffs officiels) le vérifient fichier par fichier.
  • defined('MOODLE_INTERNAL') || die(); : cette ligne empêche l’exécution du fichier par un accès HTTP direct (https://monsite/local/monplugin/version.php). Sans elle, PHP exécuterait le fichier hors du contexte Moodle, ce qui au mieux provoque une erreur bavarde (divulgation de chemins), au pire un comportement inattendu. La règle moderne : ce garde-fou est requis dans tout fichier qui n’inclut pas lui-même config.php — donc dans version.php, tous les fichiers de db/, lib.php, settings.php… Les fichiers de classes/ contenant uniquement des déclarations (namespace + classe, sans code exécutable au niveau du fichier) en sont dispensés par les conventions actuelles, mais l’ajouter n’est jamais une faute.

⚠️ Piège : n’écrivez jamais de logique conditionnelle dans version.php (« si telle constante existe alors requires = … »). Ce fichier est parsé par le gestionnaire de plugins dans des contextes variés (page d’administration, CLI, vérification de dépendances d’un AUTRE plugin), et toute dépendance à l’environnement le rend fragile. Des affectations, rien d’autre.

📚 Aller plus loin : la référence complète de chaque propriété se trouve sur moodledev.io/docs/5.2/apis/commonfiles/version.php .


3. Le dossier lang/ : les chaînes de langue

Tout texte affiché par votre plugin doit passer par l’API de chaînes (get_string() côté PHP, helper {{#str}} côté Mustache, module core/str côté JavaScript). Le dossier lang/en/ contient le fichier maître.

Nommage : le fichier porte le nom exact du composant

Le fichier doit s’appeler {component}.php, soit pour nous lang/en/local_monplugin.php. Une faute de frappe dans ce nom et toutes vos chaînes s’afficheront sous la forme [[pluginname]] — c’est d’ailleurs le symptôme à reconnaître immédiatement : des doubles crochets à l’écran signifient « chaîne introuvable », et la première cause est un nom de fichier ou un identifiant de chaîne erroné.

Fichier : local/monplugin/lang/en/local_monplugin.php

<?php // This file is part of Moodle - https://moodle.org/ // (en-tête GPL complet omis ici pour la lisibilité — il est obligatoire dans le vrai fichier) /** * Chaînes de langue anglaises de local_monplugin. * * @package local_monplugin * @copyright 2026 Alex Lemia <alexlemia13@gmail.com> * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); // OBLIGATOIRE : le nom du plugin tel qu'affiché partout dans l'interface. $string['pluginname'] = 'My plugin'; // Convention : les chaînes de capabilities s'appellent '{nom}:{capability}'. // Elles sont affichées dans l'interface de gestion des rôles. $string['monplugin:manage'] = 'Manage my plugin items'; $string['monplugin:view'] = 'View my plugin items'; // Convention : les chaînes de la Privacy API commencent par 'privacy:'. $string['privacy:metadata'] = 'The My plugin plugin does not store any personal data.'; // Convention : les tâches planifiées ont une chaîne descriptive. $string['task_cleanup'] = 'Clean up expired my plugin items'; // Convention : réglages de settings.php — une paire {clé} / {clé}_desc. $string['enablefeature'] = 'Enable the feature'; $string['enablefeature_desc'] = 'When enabled, the feature is shown to all users.'; // Placeholder simple : {$a} est remplacé par la valeur passée à get_string(). $string['itemcount'] = 'You have {$a} items.'; // Placeholder structuré : {$a->prop} pour un objet ou un tableau associatif. $string['welcome'] = 'Welcome {$a->firstname}, you last visited on {$a->date}.'; // Convention : chaînes d'événements, de messages, d'erreurs... $string['messageprovider:reminder'] = 'My plugin reminders'; $string['errornotfound'] = 'The requested item was not found.';

Et côté appel :

// Placeholder simple. echo get_string('itemcount', 'local_monplugin', 12); // Placeholder structuré. $a = (object) ['firstname' => fullname($user), 'date' => userdate($lastvisit)]; echo get_string('welcome', 'local_monplugin', $a);

Les conventions de nommage des identifiants

Quelques familles de chaînes ont un nom imposé par le cœur, qui les cherche automatiquement :

  • pluginname : le nom affiché du plugin. Toujours obligatoire. (Pour les modules d’activité, s’ajoutent modulename, modulenameplural et pluginadministration.)
  • {nomduplugin}:{capability} : par exemple monplugin:manage pour la capability local/monplugin:manage déclarée dans db/access.php. Notez que l’identifiant de chaîne ne contient pas le préfixe de type (local/) — uniquement nom:capability.
  • privacy:metadata (et ses déclinaisons privacy:metadata:{table}:{champ}) : descriptions exigées par la Privacy API.
  • messageprovider:{nom} : libellé de chaque fournisseur de messages déclaré dans db/messages.php.
  • cachedef_{nom} : description de chaque cache déclaré dans db/caches.php.

Les autres identifiants sont libres, avec la convention : minuscules, sans espaces, descriptifs (errornotfound plutôt que err1).

Pourquoi TOUJOURS écrire l’anglais d’abord : AMOS

Le fichier lang/en/ n’est pas « une langue parmi d’autres » : c’est la source de vérité. Toute l’infrastructure de traduction de Moodle — l’outil communautaire AMOS (lang.moodle.org) — part de l’anglais. Quand vous publiez un plugin dans le répertoire officiel, ses chaînes anglaises sont automatiquement importées dans AMOS, et les communautés de traducteurs (dont l’équipe francophone) les traduisent dans plus d’une centaine de langues. Les traductions sont ensuite distribuées à tous les sites Moodle via les packs de langue standards, sans que vous ayez à publier une nouvelle version du plugin.

Conséquences pratiques :

  1. Même si votre plugin est destiné à un public 100 % francophone, écrivez lang/en/ en anglais correct. Un fichier en rédigé en français casserait le flux AMOS et surprendrait tout site configuré en anglais.
  2. Pour un plugin public, la bonne pratique est de ne pas embarquer de dossier lang/fr/ et de contribuer la traduction française directement dans AMOS : elle sera maintenue par la communauté et bénéficiera des mises à jour de packs de langue.
  3. Pour un plugin privé/interne, vous pouvez parfaitement fournir la traduction dans le plugin :

Fichier : local/monplugin/lang/fr/local_monplugin.php

<?php // En-tête GPL omis pour la lisibilité. defined('MOODLE_INTERNAL') || die(); $string['pluginname'] = 'Mon plugin'; $string['monplugin:manage'] = 'Gérer les éléments de Mon plugin'; $string['monplugin:view'] = 'Consulter les éléments de Mon plugin'; $string['privacy:metadata'] = 'Le plugin Mon plugin ne conserve aucune donnée personnelle.'; $string['itemcount'] = 'Vous avez {$a} éléments.';

Il n’est pas nécessaire de traduire toutes les chaînes : Moodle retombe sur l’anglais pour toute chaîne absente du pack français.

⚠️ Piège : les chaînes de langue sont mises en cache agressivement (MUC). Après toute modification d’un fichier de langue, purgez les caches (Administration du site > Développement > Purger les caches, ou php admin/cli/purge_caches.php), sinon vous verrez l’ancienne valeur et croirez à tort que votre code est cassé. En développement, activez les réglages de cache adaptés au dev (nous y reviendrons dans la partie outillage).

💡 Pour un dev React : le couple get_string('clé', 'composant', $params) est l’équivalent direct de t('clé', params) dans i18next ou react-intl, avec une différence de philosophie : l’interpolation n’accepte qu’un seul paramètre {$a} (scalaire ou objet à propriétés). Pas de pluralisation ICU intégrée : la convention Moodle est de créer des chaînes distinctes (itemcount / itemcountplural) et de choisir côté code.

📚 Aller plus loin : String API  sur moodledev.io, et la documentation d’AMOS sur docs.moodle.org/dev/AMOS  pour le flux de traduction communautaire.


4. Le dossier db/ fichier par fichier

Le dossier db/ est le panneau de raccordement de votre plugin au cœur : chaque fichier y déclare, de manière purement descriptive, une intégration (permissions, tables, hooks, événements, tâches…). Ces fichiers sont lus à l’installation et à chaque mise à jour du plugin — d’où la règle d’or : toute modification d’un fichier de db/ doit s’accompagner d’un incrément de $plugin->version, sans quoi Moodle ne relira pas vos déclarations (elles sont mises en cache et synchronisées uniquement lors du processus d’upgrade).

4.1 db/access.php — les capabilities (permissions)

Ce fichier déclare les capabilities de votre plugin : les « droits » atomiques que le système de rôles pourra accorder ou refuser. Chaque capability est nommée {type}/{nom}:{action}.

Fichier : local/monplugin/db/access.php

<?php // En-tête GPL omis pour la lisibilité. /** * Capabilities du plugin local_monplugin. * * @package local_monplugin * @copyright 2026 Alex Lemia <alexlemia13@gmail.com> * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $capabilities = [ // Capability de consultation : lecture seule, aucun risque particulier. 'local/monplugin:view' => [ 'captype' => 'read', // 'read' ou 'write'. 'contextlevel' => CONTEXT_SYSTEM, // Niveau de contexte le plus bas où elle a du sens. 'archetypes' => [ // Attribution par défaut aux archétypes de rôles standards. 'user' => CAP_ALLOW, ], ], // Capability de gestion : écriture, avec déclaration des risques. 'local/monplugin:manage' => [ // riskbitmask : masque de bits documentant les risques qu'un rôle // doté de cette capability ferait courir au site. Valeurs combinables : // RISK_SPAM, RISK_PERSONAL, RISK_XSS, RISK_CONFIG, RISK_MANAGETRUST, RISK_DATALOSS. 'riskbitmask' => RISK_SPAM | RISK_XSS, 'captype' => 'write', 'contextlevel' => CONTEXT_SYSTEM, 'archetypes' => [ 'manager' => CAP_ALLOW, ], // clonepermissionsfrom : lors de l'INSTALLATION sur un site existant, // recopie les permissions déjà décidées pour une autre capability, // plutôt que d'appliquer les archétypes. Idéal quand une nouvelle // capability affine une capability existante. 'clonepermissionsfrom' => 'moodle/site:config', ], ];

Points essentiels à comprendre :

  • captype (read/write) alimente notamment le mode « sécurité des webservices » et les rapports : une capability write déclarée read est un bug de sécurité déclaratif.
  • contextlevel indique le niveau de contexte le plus profond où la capability est évaluable (CONTEXT_SYSTEM, CONTEXT_COURSECAT, CONTEXT_COURSE, CONTEXT_MODULE, CONTEXT_USER, CONTEXT_BLOCK). Une capability de module d’activité sera typiquement CONTEXT_MODULE.
  • archetypes ne référence pas des rôles concrets mais des archétypes (guest, user, student, teacher, editingteacher, manager) : à l’installation, chaque rôle du site hérite des permissions de son archétype. C’est pourquoi ces valeurs ne s’appliquent qu’aux nouvelles installations de la capability : si l’administrateur a personnalisé ses rôles ensuite, une mise à jour du plugin ne l’écrase pas.
  • riskbitmask est purement informatif (affiché dans l’interface de définition des rôles), mais la validation moodle.org attend qu’il soit honnête pour toute capability write.
  • Et côté langue, n’oubliez pas la chaîne associée : $string['monplugin:manage'] dans lang/en/local_monplugin.php, sans laquelle l’écran de gestion des rôles affichera un identifiant brut.

À chaque mise à jour du plugin, Moodle resynchronise les capabilities : les nouvelles sont créées, celles retirées du fichier sont supprimées (avec leurs attributions !), les modifications de riskbitmask/captype/contextlevel sont appliquées.

⚠️ Piège : renommer une capability revient à en supprimer une et en créer une autre — toutes les attributions existantes sont perdues. Si un renommage est inévitable, gérez la migration des permissions dans db/upgrade.php avant que la resynchronisation ne détruise l’ancienne.

📚 Aller plus loin : Access API  sur moodledev.io.

4.2 db/install.xml — le schéma de base de données

Ce fichier XML décrit les tables du plugin : champs, clés, index. Il est exclusivement généré par l’éditeur XMLDB intégré (Administration du site > Développement > XMLDB editor) — ne l’éditez jamais à la main, l’ordre des attributs et les commentaires générés font partie du format attendu. Nous avons étudié XMLDB en détail dans la Partie 5, chapitre 02 ; rappel minimal du rendu :

Fichier : local/monplugin/db/install.xml

<?xml version="1.0" encoding="UTF-8" ?> <XMLDB PATH="local/monplugin/db" VERSION="20260708" COMMENT="XMLDB file for Moodle local/monplugin" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="../../../lib/xmldb/xmldb.xsd" > <TABLES> <TABLE NAME="local_monplugin_items" COMMENT="Items managed by local_monplugin"> <FIELDS> <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/> <FIELD NAME="userid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/> <FIELD NAME="name" TYPE="char" LENGTH="255" NOTNULL="true" SEQUENCE="false"/> <FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/> <FIELD NAME="timemodified" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/> </FIELDS> <KEYS> <KEY NAME="primary" TYPE="primary" FIELDS="id"/> <KEY NAME="userid" TYPE="foreign" FIELDS="userid" REFTABLE="user" REFFIELDS="id"/> </KEYS> <INDEXES> <INDEX NAME="timecreated" UNIQUE="false" FIELDS="timecreated"/> </INDEXES> </TABLE> </TABLES> </XMLDB>

Retenez pour ce chapitre : install.xml n’est exécuté qu’à l’installation initiale. Toute évolution ultérieure du schéma passe par db/upgrade.php (l’éditeur XMLDB sait générer le code PHP correspondant), et install.xml doit être maintenu en parallèle pour que les nouvelles installations créent directement le schéma final. Les deux doivent toujours converger vers le même schéma — l’outil moodle-plugin-ci sait vérifier cette cohérence.

4.3 db/install.php — l’initialisation post-installation

Exécuté une seule fois, juste après la création des tables d’install.xml, et jamais lors des mises à jour. Son usage typique : insérer des données initiales, créer des enregistrements par défaut, initialiser une configuration calculée.

Fichier : local/monplugin/db/install.php

<?php // En-tête GPL omis pour la lisibilité. /** * Code exécuté après l'installation de local_monplugin. * * @package local_monplugin * @copyright 2026 Alex Lemia <alexlemia13@gmail.com> * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Point d'entrée post-installation. Le nom est imposé : * xmldb_{type}_{nom}_install (pour un mod : xmldb_{nom}_install, sans le type). * * @return bool */ function xmldb_local_monplugin_install(): bool { global $DB; // Exemple : insérer des catégories par défaut. $defaults = ['General', 'Archive']; foreach ($defaults as $i => $name) { $record = (object) [ 'userid' => 0, 'name' => $name, 'timecreated' => time(), 'timemodified' => time(), ]; $DB->insert_record('local_monplugin_items', $record); } return true; }

Notez la subtilité de nommage héritée de l’histoire : pour la plupart des types, la fonction s’appelle xmldb_{frankenstyle}_install (donc xmldb_local_monplugin_install), mais pour les modules d’activité, le préfixe de type est omis : xmldb_exemple_install pour mod_exemple. Cette exception vaut pour toutes les fonctions xmldb_* (install, upgrade, uninstall).

4.4 db/upgrade.php — les migrations

Le fichier le plus important du cycle de vie. Il contient une seule fonction, appelée avec l’ancienne version du plugin (celle stockée en base), et qui exécute séquentiellement les migrations nécessaires pour rattraper la version du code.

Fichier : local/monplugin/db/upgrade.php

<?php // En-tête GPL omis pour la lisibilité. /** * Étapes de mise à jour de local_monplugin. * * @package local_monplugin * @copyright 2026 Alex Lemia <alexlemia13@gmail.com> * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Fonction de mise à jour. Nom imposé : xmldb_{frankenstyle}_upgrade. * * @param int $oldversion version actuellement installée (lue en base). * @return bool */ function xmldb_local_monplugin_upgrade(int $oldversion): bool { global $DB; $dbman = $DB->get_manager(); // Gestionnaire DDL (création/modification de tables). // ÉTAPE 1 : la version 2026051200 a ajouté le champ 'priority'. if ($oldversion < 2026051200) { $table = new xmldb_table('local_monplugin_items'); $field = new xmldb_field('priority', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0', 'name'); if (!$dbman->field_exists($table, $field)) { $dbman->add_field($table, $field); } // SAVEPOINT : enregistre en base que le plugin est désormais // au niveau 2026051200. Arguments : (true, version, type, nom). upgrade_plugin_savepoint(true, 2026051200, 'local', 'monplugin'); } // ÉTAPE 2 : la version 2026070800 migre des données. if ($oldversion < 2026070800) { // Migration de données : normaliser les noms existants. $rs = $DB->get_recordset('local_monplugin_items'); foreach ($rs as $item) { $item->name = trim($item->name); $item->timemodified = time(); $DB->update_record('local_monplugin_items', $item); } $rs->close(); upgrade_plugin_savepoint(true, 2026070800, 'local', 'monplugin'); } return true; }

Le pattern des savepoints est capital, comprenez-le bien :

  • Chaque bloc if ($oldversion < XXX) correspond à une version publiée du plugin. Les blocs sont cumulatifs : un site en version 2026040100 qui saute directement à 2026070800 exécutera toutes les étapes intermédiaires dans l’ordre.
  • upgrade_plugin_savepoint() écrit immédiatement le nouveau numéro dans la table config_plugins. Si l’étape suivante plante, la reprise après correction ne rejouera pas les étapes déjà validées — les savepoints rendent le processus résumable. (Pour un module d’activité, la fonction équivalente est upgrade_mod_savepoint(true, XXX, 'exemple') ; il existe aussi upgrade_block_savepoint, etc.)
  • Les numéros de savepoint doivent être croissants et le dernier doit être inférieur ou égal à $plugin->version dans version.php. Moodle vérifie cette cohérence et lève une exception si un savepoint dépasse la version déclarée.

⚠️ Piège : n’utilisez jamais l’API de haut niveau de votre propre plugin (vos classes de classes/) dans upgrade.php. Au moment où une vieille étape s’exécute, votre code actuel suppose un schéma de base qui n’existe pas encore. Les étapes d’upgrade doivent être autonomes : uniquement $DB, $dbman et du SQL/DDL explicite figé dans le temps. C’est la même discipline que dans une migration Prisma : on n’importe pas ses modèles applicatifs dans une migration.

💡 Pour un dev React : db/upgrade.php = votre dossier prisma/migrations/, à une différence près : au lieu d’un fichier par migration, Moodle concatène toutes les migrations dans une seule fonction jalonnée de savepoints. Le numéro de version joue le rôle du timestamp de migration, et config_plugins celui de la table _prisma_migrations.

📚 Aller plus loin : Upgrade API  sur moodledev.io, qui détaille aussi les fonctions DDL disponibles (add_field, rename_table, add_index…).

4.5 db/uninstall.php — le ménage avant désinstallation

Exécuté avant le nettoyage automatique de la désinstallation (voir section 10). À utiliser pour supprimer ce que Moodle ne connaît pas : fichiers stockés hors des zones standard, données insérées dans des tables d’autres composants, ressources externes.

Fichier : local/monplugin/db/uninstall.php

<?php // En-tête GPL omis pour la lisibilité. /** * Nettoyage personnalisé avant désinstallation de local_monplugin. * * @package local_monplugin * @copyright 2026 Alex Lemia <alexlemia13@gmail.com> * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Nom imposé : xmldb_{frankenstyle}_uninstall. * * @return bool */ function xmldb_local_monplugin_uninstall(): bool { global $DB; // Exemple : retirer les entrées que le plugin avait créées dans une // table du cœur (Moodle ne peut pas deviner qu'elles nous appartiennent). $DB->delete_records('user_preferences', ['name' => 'local_monplugin_dismissed']); return true; }

4.6 db/hooks.php — les hooks (le mécanisme moderne)

Depuis Moodle 4.3/4.4, et de manière généralisée dans les versions 5.x, les hooks remplacent progressivement les callbacks legacy de lib.php. Le principe : le cœur (ou un autre plugin) définit une classe de hook décrivant un moment précis de l’exécution ; votre plugin déclare dans db/hooks.php qu’il veut être notifié, en pointant vers une méthode statique de rappel. C’est un vrai système de dispatcher d’événements typés, conforme à la PSR-14.

Fichier : local/monplugin/db/hooks.php

<?php // En-tête GPL omis pour la lisibilité. /** * Enregistrement des callbacks de hooks de local_monplugin. * * @package local_monplugin * @copyright 2026 Alex Lemia <alexlemia13@gmail.com> * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $callbacks = [ [ // La classe de hook écoutée : ici un hook réel du cœur, émis pendant // la génération du pied de page standard de chaque page. 'hook' => \core\hook\output\before_standard_footer_html_generation::class, // Le callback : méthode STATIQUE, désignée par sa classe et son nom. // La notation chaîne 'classe::méthode' est également acceptée. 'callback' => [\local_monplugin\hook_callbacks::class, 'before_standard_footer_html_generation'], // Priorité d'exécution parmi tous les callbacks du même hook. // Défaut : 100. Plus le nombre est ÉLEVÉ, plus le callback passe TÔT. 'priority' => 100, ], ];

Et la classe de rappel correspondante, par convention classes/hook_callbacks.php :

Fichier : local/monplugin/classes/hook_callbacks.php

<?php // En-tête GPL omis pour la lisibilité. namespace local_monplugin; use core\hook\output\before_standard_footer_html_generation; /** * Callbacks de hooks de local_monplugin. * * @package local_monplugin * @copyright 2026 Alex Lemia <alexlemia13@gmail.com> * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class hook_callbacks { /** * Ajoute un contenu au pied de page standard de toutes les pages. * * @param before_standard_footer_html_generation $hook */ public static function before_standard_footer_html_generation( before_standard_footer_html_generation $hook, ): void { // L'objet hook expose une API spécifique — ici, add_html(). $hook->add_html( \html_writer::div(get_string('pluginname', 'local_monplugin'), 'local-monplugin-footer') ); } }

Points de vigilance :

  • Les enregistrements de db/hooks.php sont mis en cache. Après tout ajout ou modification : incrémentez $plugin->version (en production) ou purgez les caches (en développement). Un hook « qui ne se déclenche pas » est presque toujours un problème de cache ou de version non incrémentée.
  • Le callback doit être une méthode statique publique ; l’objet hook reçu porte les données et les méthodes de mutation (chaque classe de hook définit sa propre API — lisez-la).
  • Certains hooks se déclenchent très tôt (avant l’initialisation complète, voire pendant l’installation) : les callbacks doivent alors vérifier l’état du système (during_initial_install(), présence de la config du plugin) avant d’agir.
  • L’administrateur peut consulter tous les hooks disponibles et tous les callbacks enregistrés via Administration du site > Développement > Aperçu des hooks (Hooks overview) — c’est votre premier réflexe pour savoir « existe-t-il un hook pour ce que je veux faire ? ».

💡 Pour un dev React : un hook Moodle n’a rien à voir avec un hook React. C’est un event bus typé : pensez à un EventEmitter où les événements sont des classes (donc autocomplétés et vérifiés par l’IDE) et les listeners déclarés statiquement dans un manifeste plutôt qu’abonnés à l’exécution. L’avantage du manifeste : Moodle connaît tous les abonnements sans charger le moindre code des plugins.

📚 Aller plus loin : Hooks API  sur moodledev.io — la page liste aussi la procédure de migration des callbacks legacy et le fonctionnement de $CFG->hooks_callback_overrides qui permet à un administrateur de désactiver ou reprioriser des callbacks.

4.7 db/events.php — les observateurs d’événements

À ne pas confondre avec les hooks : les événements (Events API) sont émis après qu’un fait s’est produit (« un utilisateur a été inscrit », « un devoir a été rendu ») et servent à la journalisation et aux réactions passives ; ils ne permettent pas de modifier le comportement en cours. Votre plugin peut observer les événements du cœur ou d’autres plugins :

Fichier : local/monplugin/db/events.php

<?php // En-tête GPL omis pour la lisibilité. /** * Observateurs d'événements de local_monplugin. * * @package local_monplugin * @copyright 2026 Alex Lemia <alexlemia13@gmail.com> * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $observers = [ [ // Nom complet de la classe d'événement observée. 'eventname' => '\core\event\user_created', // Callback : méthode statique autochargée (forme recommandée). 'callback' => '\local_monplugin\observer::user_created', // Priorité entre observateurs du même événement (plus grand = plus tôt). 'priority' => 200, // internal : true (défaut) = l'observateur est notifié DANS la transaction // en cours ; false = notifié seulement après le commit de la transaction // (indispensable si vous interagissez avec un système externe). 'internal' => true, // includefile : chemin d'un fichier à inclure avant l'appel — uniquement // pour du code legacy non autochargé. À éviter en 5.x. // 'includefile' => '/local/monplugin/locallib.php', ], [ // Le joker '*' permet d'observer TOUS les événements (usage : logging). 'eventname' => '*', 'callback' => '\local_monplugin\observer::catch_all', 'internal' => false, ], ];

La classe d’observation vit dans classes/ (par convention classes/observer.php) et reçoit l’objet événement typé. Comme pour les hooks, la liste des observateurs est cachée et resynchronisée à la mise à jour du plugin : incrément de version ou purge de caches obligatoire après modification.

📚 Aller plus loin : Events API  sur moodledev.io, qui explique aussi comment votre plugin émet ses propres événements (classes dans classes/event/).

4.8 db/services.php — fonctions externes et web services

Ce fichier expose les fonctions externes de votre plugin : les points d’API appelables via les web services (REST) et via AJAX depuis le JavaScript du frontend. La logique elle-même vit dans classes/external/ (voir section 7) ; db/services.php n’est que la déclaration.

Fichier : local/monplugin/db/services.php

<?php // En-tête GPL omis pour la lisibilité. /** * Fonctions externes et services de local_monplugin. * * @package local_monplugin * @copyright 2026 Alex Lemia <alexlemia13@gmail.com> * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $functions = [ // Convention de nommage : {frankenstyle}_{verbe}_{objet}. 'local_monplugin_create_item' => [ // Classe externe moderne : la méthode exécutée est toujours execute(). 'classname' => 'local_monplugin\external\create_item', 'description' => 'Creates a new item.', 'type' => 'write', // 'read' ou 'write' (cohérent avec l'action). 'ajax' => true, // Appelable depuis le JS du site (core/ajax). 'capabilities' => 'local/monplugin:manage', // Capabilities requises (informatif + contrôle). // 'loginrequired' => true, // Défaut : true. ], 'local_monplugin_get_items' => [ 'classname' => 'local_monplugin\external\get_items', 'description' => 'Returns the list of items for the current user.', 'type' => 'read', 'ajax' => true, 'capabilities' => 'local/monplugin:view', // 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE], // L'exposer à l'app mobile. ], ]; // (Optionnel) Service préconstruit : un "bouquet" de fonctions activable // en un clic par l'administrateur, au lieu d'un service personnalisé manuel. $services = [ 'My plugin service' => [ 'functions' => ['local_monplugin_create_item', 'local_monplugin_get_items'], 'restrictedusers' => 0, // 0 = tout utilisateur autorisé ; 1 = liste blanche. 'enabled' => 0, // Désactivé par défaut ; l'admin l'active. 'shortname' => 'local_monplugin_service', ], ];

Deux points modernes à retenir : depuis les versions 4.x, on ne déclare plus methodname (la classe externe étend core_external\external_api et expose obligatoirement execute(), plus execute_parameters() et execute_returns()) ; et le flag 'ajax' => true est ce qui rend la fonction appelable via le module JS core/ajax — c’est le canal standard de communication frontend/backend dans Moodle, l’équivalent de vos routes API.

📚 Aller plus loin : External functions API  sur moodledev.io. Nous consacrerons un chapitre entier aux fonctions externes dans la partie sur les APIs.

4.9 db/tasks.php — les tâches planifiées

Déclare les scheduled tasks du plugin et leur horaire par défaut (modifiable ensuite par l’administrateur dans l’interface). La classe de la tâche vit dans classes/task/.

Fichier : local/monplugin/db/tasks.php

<?php // En-tête GPL omis pour la lisibilité. /** * Tâches planifiées de local_monplugin. * * @package local_monplugin * @copyright 2026 Alex Lemia <alexlemia13@gmail.com> * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $tasks = [ [ // Classe de la tâche (doit étendre \core\task\scheduled_task). 'classname' => 'local_monplugin\task\cleanup', // blocking : true bloquerait l'exécution des autres tâches pendant // celle-ci. Pratiquement toujours false (et l'usage est déprécié). 'blocking' => 0, // Planification par défaut, syntaxe façon crontab. // 'R' = valeur aléatoire fixée à l'installation (répartit la charge // entre les milliers de sites Moodle qui installent le plugin). 'minute' => 'R', 'hour' => '3', 'day' => '*', 'month' => '*', 'dayofweek' => '*', // (Optionnel) Livrer la tâche désactivée par défaut. // 'disabled' => 1, ], ];

Après installation, l’administrateur retrouve la tâche dans Administration du site > Serveur > Tâches > Tâches planifiées, peut la replanifier, la désactiver, ou la lancer manuellement (php admin/cli/scheduled_task.php --execute='\local_monplugin\task\cleanup' en CLI). Comme toujours : la modification de ce fichier n’est prise en compte qu’au passage d’upgrade.

📚 Aller plus loin : Task API  sur moodledev.io — y compris les ad hoc tasks (tâches à la demande mises en file), qui ne se déclarent pas dans ce fichier mais se programment par code.

4.10 db/messages.php — les fournisseurs de messages

Si votre plugin envoie des notifications via l’API de messagerie (\core\message\message + message_send()), il doit déclarer ses message providers. Chaque provider devient une ligne dans les préférences de notification de l’utilisateur, qui choisit ses canaux (web, e-mail, mobile…).

Fichier : local/monplugin/db/messages.php

<?php // En-tête GPL omis pour la lisibilité. /** * Fournisseurs de messages de local_monplugin. * * @package local_monplugin * @copyright 2026 Alex Lemia <alexlemia13@gmail.com> * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $messageproviders = [ // Clé = nom du provider ; chaîne de langue associée : 'messageprovider:reminder'. 'reminder' => [ // (Optionnel) Capability requise pour recevoir ce type de message. 'capability' => 'local/monplugin:view', // Canaux activés par défaut avant que l'utilisateur ne personnalise. 'defaults' => [ 'popup' => MESSAGE_PERMITTED + MESSAGE_DEFAULT_ENABLED, 'email' => MESSAGE_PERMITTED, ], ], ];

4.11 db/caches.php — les définitions de caches MUC

Le Moodle Universal Cache (MUC) permet de déclarer des caches nommés, dont le mode de stockage réel (mémoire locale, fichiers, Redis…) est ensuite mappé par l’administrateur. Votre plugin déclare, le site décide où stocker.

Fichier : local/monplugin/db/caches.php

<?php // En-tête GPL omis pour la lisibilité. /** * Définitions de caches MUC de local_monplugin. * * @package local_monplugin * @copyright 2026 Alex Lemia <alexlemia13@gmail.com> * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $definitions = [ // Clé = nom du cache ; chaîne de langue associée : 'cachedef_itemsummary'. 'itemsummary' => [ // MODE_APPLICATION : partagé entre tous les utilisateurs. // MODE_SESSION : par session utilisateur. MODE_REQUEST : par requête. 'mode' => cache_store::MODE_APPLICATION, 'simplekeys' => true, // Clés = uniquement [a-zA-Z0-9_] (perf accrue). 'simpledata' => true, // Données scalaires/tableaux simples (pas d'objets). 'ttl' => 3600, // Durée de vie en secondes (0 = illimitée). 'staticacceleration' => true, // Double cache en mémoire PHP pour la requête. 'staticaccelerationsize' => 30, // Nombre max d'entrées accélérées. ], ];

Usage côté code : $cache = \cache::make('local_monplugin', 'itemsummary'); $cache->set('k', $v); $cache->get('k');.

📚 Aller plus loin : Cache API (MUC)  sur moodledev.io.

4.12 db/subplugins.json — héberger des sous-plugins

Seuls certains types de plugins (les mods, les éditeurs, les outils d’administration, les plugins locaux…) peuvent héberger leurs propres sous-plugins. Le fichier déclare les types de sous-plugins et leurs dossiers :

Fichier : local/monplugin/db/subplugins.json

{ "subplugintypes": { "monpluginsource": "sources" } }

Ici, tout dossier déposé dans local/monplugin/sources/ deviendra un plugin de type monpluginsource_*, avec sa propre anatomie complète (son version.php, son db/…). Nous avons couvert les sous-plugins au chapitre 01 ; retenez simplement où se fait la déclaration. (La clé historique "plugintypes", avec chemins complets, subsiste pour la compatibilité ; "subplugintypes", avec chemins relatifs, est la forme moderne.)

4.13 db/mobile.php — l’application mobile

Si votre plugin doit être utilisable dans l’app mobile Moodle, db/mobile.php déclare les addons mobiles : quels templates (écrits côté serveur et rendus dans l’app via Ionic/Angular) servir pour quelles vues. C’est un sujet à part entière que nous ne faisons ici que mentionner — sachez seulement que ce fichier existe et qu’il suit la même logique déclarative que ses voisins.

4.14 db/renamedclasses.php — renommer sans casser

Quand vous renommez ou déplacez une classe publique de classes/ (changement de namespace inclus), ce fichier maintient la compatibilité : il mappe les anciens noms vers les nouveaux, et l’autoloader crée des alias à la volée pour ne pas casser le code tiers qui référençait l’ancien nom.

Fichier : local/monplugin/db/renamedclasses.php

<?php // En-tête GPL omis pour la lisibilité. defined('MOODLE_INTERNAL') || die(); $renamedclasses = [ // Ancien nom (sans antislash initial) => nouveau nom complet. 'local_monplugin\manager' => 'local_monplugin\local\item_manager', ];

Réservez-le aux classes qui constituent une API publique de votre plugin ; les classes purement internes peuvent être renommées librement.


5. settings.php : la page de réglages d’administration

Si votre plugin a besoin de réglages globaux (« clé d’API », « fonctionnalité activée ? »…), le fichier settings.php les déclare. Il faut d’abord comprendre son contexte d’exécution, qui surprend toujours la première fois : ce fichier est inclus par le constructeur de l’arbre d’administration (admin tree), c’est-à-dire à chaque affichage d’une page d’administration, à chaque recherche dans l’administration… et pas seulement pour les administrateurs complets. Trois variables y sont disponibles : $ADMIN (l’arbre), $hassiteconfig (booléen : l’utilisateur courant a-t-il moodle/site:config ?) et, pour certains types de plugins, $settings.

La différence majeure selon le type de plugin :

  • Pour les modules d’activité, les blocs (ayant has_config()), les thèmes et la plupart des types, Moodle prépare une page $settings (une instance d’admin_settingpage) déjà créée et rattachée à la bonne catégorie de l’arbre. Vous n’avez qu’à y ajouter des réglages.
  • Pour les plugins locaux, rien n’est prédéfini : $settings vaut null, et c’est à vous de créer votre page (voire votre catégorie) et de l’attacher à l’arbre, classiquement sous la catégorie localplugins.

Voici le pattern complet pour notre plugin local :

Fichier : local/monplugin/settings.php

<?php // En-tête GPL omis pour la lisibilité. /** * Réglages d'administration de local_monplugin. * * @package local_monplugin * @copyright 2026 Alex Lemia <alexlemia13@gmail.com> * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); // GARDE ESSENTIELLE : ce fichier est inclus pour TOUT utilisateur ayant accès // à une partie quelconque de l'administration (ex. un enseignant consultant // les réglages de son cours). Sans cette condition, vous créeriez des pages // visibles par des non-admins, ou provoqueriez des erreurs. if ($hassiteconfig) { // Pour un plugin LOCAL : on crée soi-même la page de réglages... $settings = new admin_settingpage( 'local_monplugin', // Nom interne unique de la page. get_string('pluginname', 'local_monplugin') // Titre affiché. ); // ... et on l'attache à la catégorie "Plugins locaux" de l'arbre. $ADMIN->add('localplugins', $settings); // OPTIMISATION IMPORTANTE : $ADMIN->fulltree ne vaut true que lorsque // Moodle a besoin du CONTENU des pages (affichage ou enregistrement de // la page elle-même). Pour la simple construction du menu, il vaut false : // inutile alors d'instancier les objets de réglages (coûteux). if ($ADMIN->fulltree) { // Champ texte. Nom complet du réglage : 'local_monplugin/apikey' // (relu ensuite par get_config('local_monplugin', 'apikey')). $settings->add(new admin_setting_configtext( 'local_monplugin/apikey', // Clé (composant/nom). get_string('apikey', 'local_monplugin'), // Libellé. get_string('apikey_desc', 'local_monplugin'), // Description. '', // Valeur par défaut. PARAM_ALPHANUMEXT // Validation (type PARAM_*). )); // Case à cocher (stocke '0' ou '1'). $settings->add(new admin_setting_configcheckbox( 'local_monplugin/enablefeature', get_string('enablefeature', 'local_monplugin'), get_string('enablefeature_desc', 'local_monplugin'), 1 // Cochée par défaut. )); // Liste déroulante. $settings->add(new admin_setting_configselect( 'local_monplugin/displaymode', get_string('displaymode', 'local_monplugin'), get_string('displaymode_desc', 'local_monplugin'), 'list', // Valeur par défaut. [ 'list' => get_string('displaymode_list', 'local_monplugin'), 'grid' => get_string('displaymode_grid', 'local_monplugin'), ] )); } }

Pour comparaison, la version « module d’activité », où $settings est fourni :

Fichier : mod/exemple/settings.php

<?php // En-tête GPL omis pour la lisibilité. defined('MOODLE_INTERNAL') || die(); // $settings est déjà une admin_settingpage créée par le cœur et rattachée // à Administration du site > Plugins > Activités > Exemple. if ($ADMIN->fulltree) { $settings->add(new admin_setting_configcheckbox( 'mod_exemple/showdescription', get_string('showdescription', 'mod_exemple'), get_string('showdescription_desc', 'mod_exemple'), 0 )); }

Cas particulier des blocs : pour qu’un bloc ait un settings.php global, sa classe principale doit annoncer la couleur en surchargeant has_config() :

Fichier : blocks/exemple/block_exemple.php (extrait)

class block_exemple extends block_base { public function has_config() { return true; // Active la lecture de settings.php pour ce bloc. } // ... }

Enfin, la lecture des réglages côté code se fait toujours par get_config() :

$apikey = get_config('local_monplugin', 'apikey'); $enabled = (bool) get_config('local_monplugin', 'enablefeature');

⚠️ Piège : le piège numéro un de settings.php est d’oublier qu’il est exécuté pour des non-admins. Toute logique coûteuse (requêtes SQL, appels réseau pour peupler un select…) y est exécutée à chaque construction de l’arbre d’administration, pour beaucoup d’utilisateurs. Les parades : la garde if ($hassiteconfig), la garde if ($ADMIN->fulltree), et pour les listes calculées, les variantes « lazy » comme admin_settings_coursecat_select ou le report du calcul dans un callback. Deuxième piège : ne faites jamais de requête à la base au niveau global du fichier — la doc officielle l’interdit explicitement, car ce fichier peut être inclus pendant l’upgrade, quand le schéma n’est pas garanti.

💡 Pour un dev React : les classes admin_setting_* sont un système de formulaire déclaratif auto-persistant — imaginez un <Field> qui gérerait tout seul le stockage, la valeur par défaut, la validation PARAM_* et l’invalidation de cache. Les valeurs atterrissent dans la table config_plugins, la même table qui stocke la version du plugin : get_config('local_monplugin') sans second argument vous rend d’ailleurs l’objet complet de tous les réglages, version incluse.

📚 Aller plus loin : Admin settings  sur moodledev.io, avec le catalogue des dizaines de classes admin_setting_* disponibles (éditeur HTML, sélecteur de couleurs, durée, fichier stocké…).


6. lib.php : l’héritage à apprivoiser

Historiquement, lib.php était le point d’accroche des plugins : le cœur y découvrait des fonctions par pure convention de nommage ({frankenstyle}_nomducallback) et les appelait aux moments opportuns. Ce mécanisme de callbacks legacy fonctionne toujours en 5.2, mais il souffre de défauts structurels : pour savoir si un plugin implémente un callback, Moodle doit inclure son lib.php entier — et comme beaucoup de callbacks sont sondés sur toutes les pages, les lib.php de tous les plugins installés sont chargés très souvent. Chaque ligne superflue dans ce fichier a un coût global.

Quand lib.php est-il obligatoire ?

  • Modules d’activité (mod_*) : oui, absolument. Le contrat d’un module passe encore par des fonctions obligatoires de lib.php : exemple_add_instance(), exemple_update_instance(), exemple_delete_instance() (cycle de vie des instances dans les cours) et exemple_supports() (déclaration des fonctionnalités FEATURE_* supportées : complétion, notes, intro…). Sans elles, le module ne fonctionne pas.
  • Tous les autres types : non. lib.php n’est requis que si vous utilisez effectivement un callback legacy. Un plugin local sans callbacks n’a simplement pas de lib.php, et c’est très bien ainsi.

Les callbacks encore courants en 5.2

Malgré la migration vers les hooks, plusieurs callbacks n’ont pas (encore) d’équivalent hook et restent la voie normale :

Fichier : local/monplugin/lib.php

<?php // En-tête GPL omis pour la lisibilité. /** * Callbacks legacy de local_monplugin. * * Règle moderne : ce fichier ne contient QUE les callbacks découverts par * nom ; toute la logique réelle est déléguée à des classes autochargées. * * @package local_monplugin * @copyright 2026 Alex Lemia <alexlemia13@gmail.com> * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Sert les fichiers stockés par le plugin dans ses zones de fichiers (File API). * Appelé quand une URL pluginfile.php pointe vers ce composant. */ function local_monplugin_pluginfile($course, $cm, $context, string $filearea, array $args, bool $forcedownload, array $options = []): bool { // Déléguer immédiatement à une classe : lib.php reste un simple aiguillage. return \local_monplugin\local\file_server::serve( $context, $filearea, $args, $forcedownload, $options ); } /** * Étend la navigation principale (exemple de la famille _extend_navigation_*). */ function local_monplugin_extend_navigation_course(\navigation_node $navigation, \stdClass $course, \context_course $context): void { if (!has_capability('local/monplugin:view', $context)) { return; } $navigation->add( get_string('pluginname', 'local_monplugin'), new \moodle_url('/local/monplugin/index.php', ['courseid' => $course->id]), \navigation_node::TYPE_CUSTOM ); }

Les familles à connaître :

  • xxx_pluginfile() : indispensable dès que le plugin stocke des fichiers via la File API (contrôle d’accès au téléchargement).
  • xxx_extend_navigation_course(), xxx_extend_settings_navigation(), xxx_extend_navigation_user_settings()… : injection d’entrées dans les menus de navigation.
  • Pour les mods : xxx_get_coursemodule_info() (personnalisation de l’affichage sur la page de cours : nom dynamique, contenu inline, règles de complétion personnalisées), xxx_cm_info_view(), xxx_reset_userdata() (réinitialisation de cours), les callbacks de notes xxx_grade_item_update()
  • Divers : xxx_get_fontawesome_icon_map(), callbacks de fragments xxx_output_fragment_{name}() (chargement AJAX de morceaux de formulaires)…

Hooks d’abord : comment savoir si un hook existe

Le réflexe en 5.2, avant d’écrire un callback legacy : vérifier si un hook moderne couvre le besoin. Deux outils :

  1. Administration du site > Développement > Aperçu des hooks (Hooks overview) : la liste vivante de tous les hooks de votre installation (y compris ceux ajoutés par des plugins), avec leur description et les callbacks déjà enregistrés.
  2. La documentation moodledev.io des hooks du cœur, et le code source : les classes de hooks vivent dans lib/classes/hook/ et dans les classes/hook/ des sous-systèmes et plugins.

Quand le cœur a migré un callback vers un hook, l’ancien callback continue généralement de fonctionner un temps, mais déclenche un message de dépréciation (debugging()) vous invitant à migrer. La direction est claire : à terme, lib.php ne devrait plus contenir que les callbacks sans équivalent hook, et sa taille devrait tendre vers zéro. Toute logique métier appartient à classes/.

⚠️ Piège : n’implémentez jamais à la fois le hook et le callback legacy équivalent « pour être sûr » : selon les versions, vous seriez exécuté deux fois. Choisissez le hook dès qu’il existe pour votre version minimale supportée ($plugin->requires).

📚 Aller plus loin : la liste des callbacks legacy et leur statut de migration est documentée sur moodledev.io/docs/5.2/apis/plugintypes  (par type de plugin) et la page Hooks  tient à jour la correspondance callbacks dépréciés → hooks.


7. classes/ : le cœur moderne du plugin

Tout le PHP « sérieux » de votre plugin vit dans classes/, grâce à l’autoloader de Moodle : une classe du namespace \local_monplugin est automatiquement chargée depuis local/monplugin/classes/, en suivant une correspondance de type PSR-4 :

ClasseFichier
\local_monplugin\fooclasses/foo.php
\local_monplugin\local\item_managerclasses/local/item_manager.php
\local_monplugin\external\create_itemclasses/external/create_item.php
\local_monplugin\privacy\providerclasses/privacy/provider.php

Le premier segment du namespace est toujours le frankenstyle du composant ; le reste reflète l’arborescence sous classes/. Aucun require_once, aucun fichier d’index : vous nommez correctement, Moodle charge. (Techniquement, Moodle maintient une carte des composants dans un cache et résout le préfixe de namespace vers le bon dossier — c’est pourquoi une purge de caches est parfois nécessaire quand on ajoute un plugin entier en développement.)

Les sous-namespaces conventionnels

Certains sous-dossiers de classes/ ont un sens précis, car le cœur y cherche des classes à des noms convenus :

classes/ ├── privacy/ │ └── provider.php ← Privacy API (RGPD). Nom de classe IMPOSÉ : provider. ├── external/ ← Fonctions externes (une classe par fonction, méthode execute()). │ ├── create_item.php │ └── get_items.php ├── output/ ← Couche présentation : renderers et renderables. │ ├── renderer.php ← Le renderer du plugin (convention de nom). │ └── items_page.php ← Renderable + templatable : données pour un template Mustache. ├── form/ ← Formulaires moodleform en namespace. │ └── item_form.php ← \local_monplugin\form\item_form extends \moodleform. ├── task/ ← Tâches planifiées et ad hoc. │ ├── cleanup.php ← extends \core\task\scheduled_task (référencée par db/tasks.php). │ └── notify_user.php ← extends \core\task\adhoc_task (mise en file par code). ├── event/ ← Événements ÉMIS par le plugin. │ └── item_created.php ← extends \core\event\base ; nommage passé : {objet}_{action}. ├── hook_callbacks.php ← Convention : cibles des enregistrements de db/hooks.php. ├── observer.php ← Convention : cibles des observateurs de db/events.php. └── local/ ← VOTRE code métier, libre de toute convention du cœur. ├── item_manager.php ← Services, repositories, value objects... comme vous voulez. └── file_server.php

Le sous-namespace local\ mérite une explication : c’est la convention pour dire « ceci est interne à mon plugin, aucun contrat avec le cœur, aucune API publique ». Vous y structurez votre logique métier comme bon vous semble — c’est votre src/lib/. À l’inverse, les classes hors de local\ sont réputées suivre des contrats (interfaces du cœur, conventions de découverte) ou constituer votre API publique.

privacy/provider.php : obligatoire pour publier

La Privacy API (RGPD) impose à chaque plugin de déclarer ce qu’il fait des données personnelles. C’est vérifié par la validation automatique du répertoire moodle.org : pas de provider, pas de publication. Le cas minimal — un plugin qui ne stocke aucune donnée personnelle — se déclare avec le null_provider :

Fichier : local/monplugin/classes/privacy/provider.php

<?php // En-tête GPL omis pour la lisibilité. namespace local_monplugin\privacy; /** * Déclaration de confidentialité de local_monplugin. * * @package local_monplugin * @copyright 2026 Alex Lemia <alexlemia13@gmail.com> * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements \core_privacy\local\metadata\null_provider { /** * Retourne l'IDENTIFIANT de la chaîne de langue expliquant pourquoi * ce plugin ne stocke aucune donnée personnelle. * * @return string */ public static function get_reason(): string { return 'privacy:metadata'; } }

Avec, dans lang/en/local_monplugin.php, la chaîne correspondante : $string['privacy:metadata'] = 'The My plugin plugin does not store any personal data.';. Dès que votre plugin stocke des données liées à des utilisateurs (notre table local_monplugin_items a un champ userid !), le null_provider devient un mensonge : il faut alors implémenter les interfaces complètes (metadata\provider, request\plugin\provider, request\core_userlist_provider) qui décrivent, exportent et suppriment les données. Nous y consacrerons un chapitre dédié ; à ce stade, retenez l’exigence et son emplacement.

output/ : renderers et renderables

La couche de présentation moderne s’appuie sur le trio renderable (objet de données), templatable (sait s’exporter pour Mustache) et renderer (transforme en HTML). Exemple minimal d’un renderable :

Fichier : local/monplugin/classes/output/items_page.php

<?php // En-tête GPL omis pour la lisibilité. namespace local_monplugin\output; use renderable; use renderer_base; use templatable; /** * Données de la page de liste des items, exportées vers le template Mustache. * * @package local_monplugin * @copyright 2026 Alex Lemia <alexlemia13@gmail.com> * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class items_page implements renderable, templatable { /** @param array $items Les items à afficher. */ public function __construct( private readonly array $items, ) { } /** * Exporte les données pour le template local_monplugin/items_page. */ public function export_for_template(renderer_base $output): array { return [ 'items' => array_values($this->items), 'hasitems' => !empty($this->items), ]; } }

💡 Pour un dev React : export_for_template() est votre getServerSideProps : il transforme le modèle en « props » sérialisables (scalaires, tableaux, objets simples) pour le template. Le template Mustache est le composant de présentation pur — même philosophie de séparation données/vue, appliquée côté PHP.

Les autres sous-namespaces en bref

  • external/ : une classe par fonction externe, étendant \core_external\external_api, avec le triptyque execute_parameters() / execute() / execute_returns(). Référencées par db/services.php (section 4.8).
  • form/ : vos formulaires étendent \moodleform (définition dans definition(), validation dans validation()). L’écriture en namespace dans classes/form/ est la forme moderne — fini le form.php à la racine inclus manuellement.
  • task/ : scheduled_task (déclarées dans db/tasks.php) et adhoc_task (mises en file par \core\task\manager::queue_adhoc_task()).
  • event/ : les événements que vous émettez, avec leur nommage à l’anglaise au participe passé (item_created, item_deleted), leurs métadonnées (crud, edulevel, objecttable) et leur émission par item_created::create(...)->trigger().

⚠️ Piège : le nom du fichier doit correspondre exactement au nom de la classe, en minuscules, et une seule classe par fichier. classes/local/ItemManager.php contenant class item_manager ne sera jamais autochargé. La convention Moodle pour les identifiants est le snake_case intégral — désorientant après TypeScript, mais uniforme dans tout l’écosystème.


8. Ressources front-end et fichiers annexes

templates/ — les templates Mustache

Chaque fichier .mustache de ce dossier devient un template adressable sous le nom {component}/{fichier} : templates/card.mustache s’appelle local_monplugin/card. Il se rend côté PHP ($OUTPUT->render_from_template('local_monplugin/card', $data)) et côté JavaScript (module core/templates), avec le même moteur — c’est l’atout majeur du choix Mustache (logique-less, donc portable). Les sous-dossiers sont permis (templates/local/detail.mustachelocal_monplugin/local/detail). Nous consacrons la Partie 7 au rendu (templates, renderers, output API) ; retenez ici l’emplacement et le nommage.

amd/src/ — le JavaScript

Le JavaScript moderne de Moodle s’écrit en modules ES6 dans amd/src/, puis est transpilé et minifié vers amd/build/ par la chaîne Grunt du projet (npx grunt amd depuis la racine, ou npx grunt watch en développement). Le module amd/src/controls.js devient adressable sous le nom local_monplugin/controls et se charge depuis PHP par $PAGE->requires->js_call_amd('local_monplugin/controls', 'init', [$params]). Le dossier build/ fait partie du plugin distribué (les sites n’ont pas Node.js) mais ne s’édite jamais à la main. Détails complets en Partie 7.

⚠️ Piège : si vous modifiez amd/src/ sans relancer Grunt, le navigateur continue de servir l’ancien amd/build/ — symptôme classique du « mon JS ne se met pas à jour ». En développement, $CFG->cachejs = false; dans config.php fait servir les sources, mais le build reste requis avant distribution.

pix/ — les icônes

pix/icon.svg (avec repli icon.png) est l’icône générique du plugin, adressable par $OUTPUT->pix_icon('icon', $alt, 'local_monplugin'). Depuis Moodle 4.x, les modules d’activité fournissent en plus pix/monologo.svg : une icône monochrome que le thème colore automatiquement selon la catégorie pédagogique de l’activité (les fameuses pastilles colorées de la page de cours). Un mod_* sans monologo.svg s’affiche avec son ancienne icône détourée, signe visuel d’un plugin non modernisé.

styles.css — le CSS… global

Tout fichier styles.css à la racine d’un plugin est automatiquement agrégé dans la feuille de style unique du thème, compilée et servie sur toutes les pages du site. Aucune déclaration nécessaire — mais comprenez la conséquence : vos règles CSS sont globales. Un sélecteur .card ou .item dans votre styles.css affecterait tout le site.

Fichier : local/monplugin/styles.css

/* local_monplugin — toutes les règles préfixées par une classe racine posée sur le conteneur du plugin, pour éviter toute fuite globale. */ .local_monplugin .item-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); gap: 1rem; } .local_monplugin .item-card { border: 1px solid var(--bs-border-color); border-radius: 0.5rem; padding: 1rem; }

⚠️ Piège : il n’y a aucun scoping automatique (pas de CSS Modules, pas de Shadow DOM). La discipline est contractuelle : préfixez systématiquement vos sélecteurs par une classe portant le nom du composant (.local_monplugin, ou le préfixe .local-monplugin- sur chaque classe). Et souvenez-vous que ce CSS est compilé dans le cache du thème : après modification, purgez les caches (ou activez $CFG->themedesignermode en développement… en acceptant sa lenteur).

README.md, CHANGES.md et LICENSE

Pour toute distribution : un README.md décrivant l’installation et la configuration (le répertoire de plugins l’affiche), un CHANGES.md (importé automatiquement comme notes de version lors des dépôts sur moodle.org), et la licence. Sur ce dernier point, aucune marge de manœuvre : Moodle est sous GPLv3, et tout plugin distribué, parce qu’il dérive des APIs de Moodle, doit être GPLv3 (ou compatible ultérieure). Le fichier LICENSE contient le texte intégral de la licence. Les bibliothèques tierces embarquées doivent être GPL-compatibles et déclarées dans thirdpartylibs.xml.

Tests et intégration continue (mention)

  • tests/*_test.php : tests PHPUnit (classes étendant advanced_testcase, avec les data generators de Moodle).
  • tests/behat/*.feature : tests d’acceptation Behat (Gherkin), rejouant des scénarios utilisateur dans un vrai navigateur.
  • .github/workflows/ci.yml avec moodle-plugin-ci : l’outil officiel de CI, qui enchaîne linting (phpcs avec les règles Moodle), analyse (phpdoc, mustache lint, grunt), et exécution PHPUnit/Behat contre plusieurs versions de Moodle et de bases de données.
  • composer.json : permet l’installation du plugin via Composer (type moodle-local, etc.) dans les workflows d’infrastructure-as-code.

Nous traiterons l’outillage qualité en Partie 11 ; à ce stade, sachez que ces emplacements sont eux aussi conventionnels et découverts automatiquement (vendor/bin/phpunit de Moodle trouve vos tests sans configuration).

📚 Aller plus loin : moodle-plugin-ci  et la page Testing  de moodledev.io.


9. Le cycle d’installation pas à pas

Vous avez déposé votre dossier monplugin/ dans public/local/. Que se passe-t-il exactement ? Suivons le fil, car comprendre ce mécanisme vous fera gagner des heures de débogage.

Étape 1 : la détection

Moodle ne surveille pas le système de fichiers en continu. La détection a lieu quand le gestionnaire de plugins (core_plugin_manager) reconstruit sa liste, c’est-à-dire :

  • quand un administrateur visite une page d’administration (en particulier Administration du site > Notifications, la page qui force la vérification) ;
  • quand on exécute la mise à jour en CLI : php admin/cli/upgrade.php (la méthode recommandée sur tout site sérieux, et la seule scriptable).

Le gestionnaire scanne les dossiers de chaque type de plugin, lit le version.php de chaque dossier trouvé, et compare $plugin->version avec la valeur stockée en base.

Étape 2 : la comparaison avec config_plugins

L’état installé de chaque plugin est stocké dans la table config_plugins, qui contient à la fois les réglages (section 5) et la version, sous la forme d’une ligne « pseudo-réglage » :

mdl_config_plugins ┌────────┬──────────────────┬───────────┬─────────────┐ │ id │ plugin │ name │ value │ ├────────┼──────────────────┼───────────┼─────────────┤ │ 4217 │ local_monplugin │ version │ 2026070800 │ │ 4218 │ local_monplugin │ apikey │ sk-abc123 │ │ 4219 │ local_monplugin │ enable... │ 1 │ └────────┴──────────────────┴───────────┴─────────────┘

Trois cas :

  1. Aucune ligne version pour ce composant → plugin à installer.
  2. Ligne présente, valeur inférieure à celle du code → plugin à mettre à jour.
  3. Ligne présente, valeur supérieure à celle du code → erreur bloquante (tentative de downgrade).

C’est aussi là que sont vérifiées les contraintes de version.php : $plugin->requires contre la version du cœur, $plugin->incompatible, et les $plugin->dependencies contre les versions des autres plugins. Toute contrainte insatisfaite bloque le processus avec un message explicite.

Étape 3 : l’écran de confirmation

En mode web, Moodle affiche la liste des plugins à installer/mettre à jour (avec leur origine, leur maturité, les dépendances résolues) et demande confirmation — c’est un garde-fou : rien n’est exécuté tant que l’administrateur n’a pas cliqué « Mettre à jour la base de données ». En CLI, admin/cli/upgrade.php affiche l’équivalent et demande confirmation (--non-interactive pour les scripts de déploiement).

⚠️ Piège : pendant toute la fenêtre où du code nouveau est présent sur le disque mais pas encore « committé » en base, le site est dans un état hybride. Sur un site de production, utilisez le mode maintenance (php admin/cli/maintenance.php --enable) autour du déploiement : le code des plugins est exécuté par les requêtes des utilisateurs même avant que l’upgrade soit jouée, ce qui peut produire des erreurs si le code suppose le nouveau schéma.

Étape 4 : l’installation proprement dite

Pour un plugin nouveau, les opérations s’enchaînent dans cet ordre précis :

  1. Création des tables décrites dans db/install.xml (via le gestionnaire DDL, de façon portable entre MySQL/MariaDB/PostgreSQL…).
  2. Exécution de db/install.php (xmldb_local_monplugin_install()), si présent — vos données initiales.
  3. Enregistrement de la version : la ligne version est écrite dans config_plugins. À partir de cet instant, le plugin est « installé ».
  4. Synchronisation des manifestes de db/ : les capabilities d’access.php sont créées (et attribuées selon les archétypes ou clonepermissionsfrom), les observateurs d’events.php, les tâches de tasks.php (avec leur horaire par défaut, les R étant tirés au sort maintenant), les providers de messages.php, les définitions de caches.php, les callbacks de hooks.php et les fonctions de services.php sont enregistrés dans leurs tables et caches respectifs.
  5. Purge des caches : caches de langue, de thème, carte des composants, arbres d’administration… tout ce qui pouvait référencer l’ancien état est invalidé.

Notez le point 2 → 3 : install.php s’exécute avant que les capabilities et autres manifestes soient synchronisés. N’y utilisez donc pas vos propres capabilities.

Étape 5 : la mise à jour

Pour un plugin déjà installé dont la version du code a augmenté :

  1. Exécution de db/upgrade.php : xmldb_local_monplugin_upgrade($oldversion) déroule ses blocs if ($oldversion < ...), chaque upgrade_plugin_savepoint() avançant le compteur en base au fur et à mesure.
  2. Enregistrement de la version finale dans config_plugins (celle de version.php).
  3. Resynchronisation des manifestes de db/ : c’est ici que vos nouvelles capabilities, tâches, hooks, observers, services… sont pris en compte, et que les entrées supprimées des fichiers sont retirées du système.
  4. Purge des caches, comme à l’installation.

Vous comprenez maintenant mécaniquement pourquoi « modifier un fichier de db/ sans toucher version.php ne fait rien » : la resynchronisation des manifestes n’a lieu que pendant ce processus, qui n’est déclenché que par une différence de version. (En développement, la purge de caches force la relecture de certains manifestes comme les hooks, mais pas tous — l’incrément de version reste le geste fiable.)

Quand une upgrade plante à mi-chemin

Scénario vécu par tout développeur : votre étape d’upgrade lève une exception à mi-parcours. Que faire ?

  • Grâce aux savepoints, le processus est résumable : les étapes déjà validées ont enregistré leur numéro en base. Corrigez le code de l’étape fautive et relancez l’upgrade (rechargez Notifications ou relancez le CLI) : Moodle reprend exactement où il s’est arrêté, sans rejouer ce qui a réussi.
  • Attention aux étapes non atomiques : les changements DDL (création de table, ajout de champ) ne sont pas transactionnels sur tous les SGBD. Si votre étape a créé un champ puis planté avant son savepoint, la relance retentera de créer le champ. D’où le style défensif de l’exemple de la section 4.4 : if (!$dbman->field_exists(...)) avant add_field(). Prenez ce réflexe systématiquement.
  • Faut-il modifier la version dans config_plugins à la main ? En production : non, jamais — c’est le chemin le plus court vers un site incohérent. En développement, c’est parfois un raccourci acceptable pour rejouer une étape (redescendre la valeur du version du plugin en base, purger les caches, relancer), mais la bonne pratique de dev reste plus radicale : désinstallez et réinstallez le plugin (php admin/cli/uninstall_plugins.php --plugins=local_monplugin --run), ce qui rejoue le chemin install.xml + install.php complet et vérifie du même coup que votre install.xml est bien synchronisé avec vos upgrades. Testez toujours les deux chemins (installation fraîche et upgrade depuis la version publiée précédente) avant de publier.

💡 Pour un dev React : le couple « relance résumable + étapes idempotentes » est exactement la discipline des migrations de base dans un pipeline CI/CD. Considérez chaque bloc d’upgrade comme un job at-least-once : il doit pouvoir être rejoué sans dégât. Les fonctions field_exists(), table_exists(), index_exists() du gestionnaire DDL sont vos outils d’idempotence.

📚 Aller plus loin : Upgrade API  et la page du gestionnaire de plugins moodledev.io/docs/5.2/apis/subsystems/admin  détaillent le processus, y compris les subtilités multi-plugins (ordre : le cœur d’abord, puis les plugins par type).


10. La désinstallation propre

La désinstallation s’effectue depuis Administration du site > Plugins > Vue d'ensemble des plugins (bouton « Désinstaller »), ou en CLI : php admin/cli/uninstall_plugins.php --plugins=local_monplugin --run.

Ce que Moodle supprime automatiquement

Le cœur fait un ménage considérable, dans cet ordre :

  1. Exécution de db/uninstall.php (xmldb_local_monplugin_uninstall()), si présent — avant tout le reste, pendant que vos tables existent encore.
  2. Suppression spécifique au type de plugin : pour un mod_*, toutes les instances de l’activité dans tous les cours sont supprimées (avec leurs notes, leurs fichiers, leurs données de complétion) ; pour un block_*, toutes les instances de blocs ; pour un enrol_*, toutes les inscriptions associées, etc.
  3. Suppression des tables : toutes les tables listées dans db/install.xml sont supprimées (DROP TABLE). Oui, c’est bien le fichier install.xml qui fait foi ici — une table créée par une étape d’upgrade mais oubliée dans install.xml serait orpheline (raison supplémentaire de garder les deux synchronisés).
  4. Suppression des capabilities déclarées dans db/access.php, avec toutes leurs attributions dans tous les rôles et contextes.
  5. Suppression de la configuration : toutes les lignes du composant dans config_plugins (réglages et version).
  6. Désenregistrement des intégrations : observateurs d’événements, tâches planifiées (et leur historique), fonctions et services externes, providers de messages, définitions de caches, callbacks de hooks.
  7. Suppression des fichiers stockés par le composant dans les zones de la File API, des préférences utilisateur préfixées et des autres données rattachées au composant.
  8. Purge des caches.

Ce que Moodle ne supprime PAS : les fichiers du dossier

Moodle ne touche jamais au code source : le dossier public/local/monplugin/ reste sur le disque après désinstallation. Conséquence immédiate et déroutante : à la prochaine visite de la page Notifications, Moodle redétecte le dossier et… propose de réinstaller le plugin. La désinstallation n’est donc complète qu’après suppression manuelle du dossier — ou, dans un déploiement moderne, après son retrait du dépôt git / du script de build qui assemble le site. (L’interface web propose de supprimer le dossier à votre place uniquement lorsque le processus PHP a les droits d’écriture dessus, ce qui est rare et déconseillé en production.)

⚠️ Piège : l’ordre est important — désinstallez avant de retirer les fichiers. Si vous supprimez d’abord le dossier, Moodle affiche le plugin comme « manquant en base de données » (missing from disk) : les données et réglages sont toujours là, mais le code qui saurait les désinstaller proprement (votre uninstall.php !) a disparu. L’interface permet certes de désinstaller un plugin manquant, mais sans exécuter votre ménage personnalisé.

Le cas des sous-plugins

Si votre plugin héberge des sous-plugins (db/subplugins.json), Moodle désinstalle d’abord tous les sous-plugins (chacun avec son cycle complet : leur uninstall.php, leurs tables, leurs capabilities…), puis le plugin parent. Symétriquement, la présence de dépendances inverse le blocage : impossible de désinstaller un plugin dont un autre plugin installé déclare dépendre (via $plugin->dependencies) sans désinstaller ce dernier d’abord.

Check-list du développeur pour une désinstallation irréprochable

  • Toute table du plugin figure dans install.xml (sinon : table orpheline).
  • Toute donnée écrite hors des tables du plugin (préférences exotiques, entrées dans des tables du cœur, fichiers hors File API, ressources externes) est nettoyée par db/uninstall.php.
  • Le plugin se réinstalle proprement après désinstallation (test à automatiser : install → uninstall → install).

11. Check-list finale : l’anatomie minimale par situation

Terminons par le mémento inverse : non plus « tout ce qui peut exister », mais « le strict nécessaire selon le besoin ». Partez toujours du minimum et n’ajoutez un fichier que lorsque le besoin correspondant apparaît.

Le plugin viable minimal

Deux fichiers. C’est tout.

local/monplugin/ ├── version.php ← component, version, requires, maturity, release. └── lang/en/local_monplugin.php ← $string['pluginname'] au minimum.

Un tel plugin s’installe, apparaît dans la liste des plugins, et ne fait rien — c’est votre hello world, et le point de départ du tutoriel du chapitre suivant. Pour une publication sur moodle.org, ajoutez immédiatement le troisième quasi-indispensable : classes/privacy/provider.php (null_provider), plus README.md et LICENSE.

Ce qui s’ajoute, besoin par besoin

BesoinFichiers à ajouter
Une page web visitableindex.php (ou view.php…), et généralement db/access.php pour en contrôler l’accès
Des permissionsdb/access.php + chaînes monplugin:xxx dans lang/en/
Des tables en basedb/install.xml (éditeur XMLDB) + db/upgrade.php dès la 2ᵉ version du schéma
Des données initialesdb/install.php
Réagir à un point d’extension du cœurdb/hooks.php + classes/hook_callbacks.php (vérifier d’abord l’aperçu des hooks)
Réagir à un fait accompli (log, notification…)db/events.php + classes/observer.php
Un callback sans équivalent hook (fichiers, navigation…)lib.php (le plus mince possible)
Des réglages d’administrationsettings.php + chaînes associées
Une API appelable en AJAX / web servicedb/services.php + classes/external/*.php
Du travail périodique (cron)db/tasks.php + classes/task/*.php + chaîne de nom de tâche
Des notifications aux utilisateursdb/messages.php + chaîne messageprovider:xxx
Du cache applicatifdb/caches.php + chaîne cachedef_xxx
Du rendu HTML structurétemplates/*.mustache + classes/output/*.php
De l’interactivité clientamd/src/*.js (+ build Grunt dans amd/build/)
Une identité visuellepix/icon.svg (+ pix/monologo.svg pour un mod)
Des stylesstyles.css (préfixés !)
Stocker/servir des fichiers utilisateurFile API + callback xxx_pluginfile() dans lib.php
Données personnelles stockéesclasses/privacy/provider.php complet (plus null_provider)
Distribution publiqueREADME.md, CHANGES.md, LICENSE (GPLv3), classes/privacy/provider.php, CI moodle-plugin-ci
Support de l’app mobiledb/mobile.php
Refactoring de classes publiquesdb/renamedclasses.php
Héberger des sous-pluginsdb/subplugins.json + dossier dédié

Les cinq réflexes à ancrer dès maintenant

  1. Toute modification de db/ ou de version.php → incrément de $plugin->version, sinon rien n’est pris en compte.
  2. Après toute modification de langue, template, CSS ou hook en développement → purge des caches (php admin/cli/purge_caches.php).
  3. lib.php minimal, classes/ maximal : le premier est chargé partout, le second à la demande.
  4. Hooks d’abord, callbacks legacy ensuite : consultez l’aperçu des hooks avant d’écrire dans lib.php.
  5. Testez les deux chemins : installation fraîche (via install.xml) et mise à jour (via upgrade.php) doivent aboutir au même état.

Dans le chapitre suivant

Vous connaissez désormais le squelette commun à tous les plugins : la carte d’identité version.php, les chaînes de langue, les manifestes déclaratifs de db/, les réglages, l’héritage lib.php, le code moderne de classes/, et le cycle de vie complet installation → mise à jour → désinstallation. Il est temps de donner chair à ce squelette : dans le chapitre 03 — Tutoriel complet : un plugin local de A à Z, nous construirons pas à pas un vrai plugin local_todolist — une liste de tâches personnelle pour chaque utilisateur — en mobilisant, dans l’ordre où on les rencontre réellement en développement, chacun des fichiers étudiés ici : version.php, les chaînes de langue, une table XMLDB, des capabilities, une page avec formulaire, un template Mustache et une première fonction externe appelée en AJAX. Vous verrez l’anatomie s’animer.