Chapitre 02 — La base de données : XMLDB et DML
Si vous venez du monde Next.js/React, vous avez probablement une relation intime avec votre ORM : un schema.prisma qui décrit vos tables, des migrations générées automatiquement, un client typé qui vous donne prisma.user.findMany(). Moodle a résolu exactement les mêmes problèmes — définir un schéma portable, le faire évoluer, et interroger la base sans écrire de SQL spécifique à un SGBD — mais il l’a fait quinze ans avant Prisma, en PHP, et avec des choix d’architecture qui lui sont propres.
Ce chapitre est probablement le plus important de toute cette documentation. La couche base de données de Moodle irrigue absolument tout : chaque plugin, chaque page, chaque tâche planifiée passe par elle. Et surtout, comprendre le schéma mental des tables cœur (section 9) est ce qui sépare le développeur qui « bricole » de celui qui comprend réellement ce qui se passe quand un étudiant s’inscrit à un cours ou ouvre un quiz.
Deux acronymes vont structurer notre parcours :
- XMLDB : le système de définition du schéma (l’équivalent de
schema.prisma+ le moteur de migrations) ; - DML (Data Manipulation Library) : l’API de lecture/écriture des données (l’équivalent du client Prisma/Drizzle).
Mini-sommaire
- Philosophie : une abstraction totale de la base de données
- XMLDB — définir le schéma
- version.php et les upgrades
- DML — lire des données
- DML — écrire des données
- Les placeholders : requêtes paramétrées obligatoires
- Helpers SQL cross-DB
- Transactions déléguées
- Le schéma mental des tables cœur
- Résumé — points clés
1. Philosophie : une abstraction totale de la base de données
1.1 Pourquoi une couche d’abstraction ?
Moodle est déployé sur des centaines de milliers de sites dans le monde : des universités avec des DBA PostgreSQL exigeants, des hébergeurs mutualisés sous MySQL, des institutions Microsoft qui imposent SQL Server. Dès ses débuts, Moodle a fait un choix radical : le code applicatif ne doit jamais savoir sur quel SGBD il tourne.
Concrètement, en Moodle 5.2, les SGBD officiellement supportés sont :
| SGBD | Statut en Moodle 5.2 |
|---|---|
| MySQL 8.4+ | Supporté |
| MariaDB 10.11+ | Supporté |
| PostgreSQL 14+ | Supporté (et recommandé par une bonne partie de la communauté) |
| SQL Server 2019+ | Supporté |
| Oracle Database | Retiré depuis Moodle 5.0 (déprécié en 4.5, qui fut la dernière version compatible) |
Le retrait d’Oracle mérite une note historique : il découle du désengagement de PHP lui-même (les extensions oci8/pdo_oci ont été sorties du cœur de PHP 8.4 vers PECL) et du très faible usage d’Oracle dans la communauté Moodle. Si vous lisez du vieux code core, vous croiserez encore des commentaires du type « Oracle doesn’t support… » : ce sont des fossiles, mais ils expliquent certains choix de conception qui perdurent (comme les précautions autour des colonnes TEXT, qu’on verra en section 7).
Cette abstraction impose une règle d’or absolue : on n’écrit jamais de SQL utilisant une fonction ou une syntaxe propre à un SGBD (CONCAT() de MySQL, ILIKE de PostgreSQL, TOP de SQL Server, LIMIT dans le SQL brut…). Quand on a besoin d’une fonctionnalité qui diffère entre SGBD, on passe par un helper de l’objet $DB (section 7), et quand on a besoin de pagination, on la passe en paramètre PHP ($limitfrom, $limitnum) et c’est le driver qui génère le LIMIT/OFFSET/FETCH FIRST adapté.
💡 Pour un dev React : c’est exactement la philosophie de Prisma ou Drizzle : votre code appelle
findMany({ take: 10, skip: 20 })et l’ORM traduit enLIMIT 10 OFFSET 20ou enFETCH FIRSTselon le provider. La différence, c’est que Moodle vous laisse quand même écrire du SQL « presque brut » quand c’est nécessaire (viaget_records_sql()), mais dans un dialecte neutre, avec des helpers pour les points de divergence. Pensez-y comme au$queryRawde Prisma, mais institutionnalisé et outillé.
1.2 Le préfixe de table et la syntaxe {tablename}
Toutes les tables Moodle partagent un préfixe configurable, défini dans config.php :
// config.php — configuration de la connexion.
$CFG->dbtype = 'pgsql'; // Driver : pgsql, mariadb, mysqli, sqlsrv.
$CFG->dblibrary = 'native'; // Toujours 'native' de nos jours.
$CFG->dbhost = 'localhost';
$CFG->dbname = 'moodle';
$CFG->dbuser = 'moodleuser';
$CFG->dbpass = 'secret';
$CFG->prefix = 'mdl_'; // LE préfixe. 'mdl_' par convention, mais libre.Le préfixe mdl_ est la valeur par défaut historique, mais rien ne garantit qu’un site l’utilise (certains hébergent plusieurs Moodle dans une même base avec des préfixes différents). Conséquence : vous n’écrivez jamais mdl_user dans une requête. À la place, Moodle fournit la syntaxe {tablename} :
// CORRECT : le nom entre accolades est remplacé par le vrai nom préfixé
// (et correctement quoté pour le SGBD) au moment de l'exécution.
$sql = "SELECT u.id, u.firstname, u.lastname
FROM {user} u
JOIN {user_enrolments} ue ON ue.userid = u.id
WHERE ue.enrolid = :enrolid";
$users = $DB->get_records_sql($sql, ['enrolid' => $enrolid]);// INCORRECT — ne compile même pas mentalement pour un développeur Moodle :
$sql = "SELECT * FROM mdl_user WHERE id = 3"; // Préfixe en dur : interdit.
$sql = "SELECT * FROM " . $CFG->prefix . "user"; // Concaténation manuelle : interdit aussi.Cette substitution est faite par la couche DML avant d’envoyer la requête au driver. Elle a un bonus appréciable : Moodle en profite pour ajouter les guillemets d’identifiants adaptés ("user" sous PostgreSQL, où user est un mot réservé, backticks sous MySQL).
⚠️ Piège : quand vous utilisez les méthodes « haut niveau » du DML (
get_record(),insert_record()…), vous passez le nom de table sans accolades ni préfixe :$DB->get_record('user', ...). Les accolades ne servent que dans les chaînes SQL brutes des méthodes*_sql()etexecute(). Mélanger les deux ($DB->get_record('{user}', ...)) provoque une erreur « table not found » assez déroutante la première fois.
1.3 L’objet $DB : votre unique porte d’entrée
Toute l’API DML est exposée par un objet global, $DB, instance d’une sous-classe de moodle_database (une par driver : pgsql_native_moodle_database, mysqli_native_moodle_database, etc.). Dans une fonction, on le récupère par déclaration globale :
function local_monplugin_count_items(int $courseid): int {
global $DB; // Le réflexe Moodle. Dans les classes modernes, on peut aussi
// utiliser l'injection via \core\di, mais $DB reste omniprésent.
return $DB->count_records('local_monplugin_items', ['courseid' => $courseid]);
}Il n’y a pas de modèles/entités au sens ORM : le DML retourne des stdClass bruts (un objet par ligne, une propriété publique par colonne). C’est spartiate comparé aux types générés par Prisma, mais c’est uniforme, prévisible, et ça n’a jamais changé en vingt ans — votre code de 2026 ressemblera à celui écrit pour Moodle 2.0. Moodle propose par ailleurs une couche optionnelle de type Active Record (core\persistent, abordée dans un chapitre ultérieur) construite au-dessus du DML, mais le DML reste le socle que vous devez maîtriser d’abord.
📚 Aller plus loin : la page de référence officielle de la couche d’accès aux données est Data manipulation API sur moodledev.io — gardez-la en favori, c’est l’une des pages les plus consultées de toute la documentation développeur.
2. XMLDB — définir le schéma
2.1 db/install.xml : le schema.prisma de Moodle
Chaque plugin (et le core lui-même, via lib/db/install.xml) décrit ses tables dans un fichier db/install.xml. Ce fichier est lu à l’installation du plugin : Moodle le parse, puis génère et exécute le DDL (CREATE TABLE…) adapté au SGBD courant. Vous ne créez donc jamais une table à la main dans un client SQL.
💡 Pour un dev React :
install.xml↔schema.prisma. Même rôle (source de vérité du schéma, agnostique du SGBD), même workflow (on édite le fichier, un outil génère le DDL). Deux différences majeures : ①install.xmlne décrit que l’état initial/actuel — l’évolution du schéma passe par un fichier séparé,db/upgrade.php(l’équivalent du dossiermigrations/, voir section 3) ; ② on n’édite pasinstall.xmlà la main mais via un éditeur graphique intégré (section 2.6), car le format exige un ordre d’attributs strict et des métadonnées de cohérence.
2.2 Anatomie complète d’un install.xml
Voici le db/install.xml d’un plugin local fictif local_bookshelf qui gère des listes de lecture par cours. Lisez-le ligne à ligne, chaque concept est commenté ensuite :
<?xml version="1.0" encoding="UTF-8" ?>
<XMLDB PATH="local/bookshelf/db" VERSION="20260708" COMMENT="XMLDB file for local_bookshelf"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="../../../lib/xmldb/xmldb.xsd"
>
<TABLES>
<TABLE NAME="local_bookshelf_books" COMMENT="Books recommended within a course.">
<FIELDS>
<!-- Chaque table Moodle DOIT avoir un champ id auto-incrémenté. -->
<FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
<!-- Référence vers le cours. Type int(10) : la taille standard pour les FK. -->
<FIELD NAME="courseid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
<!-- Chaîne courte : char avec LENGTH obligatoire (max 1333). -->
<FIELD NAME="title" TYPE="char" LENGTH="255" NOTNULL="true" SEQUENCE="false"/>
<FIELD NAME="isbn" TYPE="char" LENGTH="20" NOTNULL="false" SEQUENCE="false"/>
<!-- Texte long : pas de LENGTH, stocké en TEXT/CLOB selon le SGBD. -->
<FIELD NAME="summary" TYPE="text" NOTNULL="false" SEQUENCE="false"/>
<!-- Le format du champ summary (HTML, Markdown...) : convention Moodle
pour tout champ texte éditable, on stocke le format à côté. -->
<FIELD NAME="summaryformat" TYPE="int" LENGTH="2" NOTNULL="true" DEFAULT="1" SEQUENCE="false"/>
<!-- Nombre décimal exact : NUMBER avec LENGTH (total) et DECIMALS. -->
<FIELD NAME="price" TYPE="number" LENGTH="10" NOTNULL="false" DECIMALS="2" SEQUENCE="false"/>
<!-- Booléen ? Il n'y en a pas en XMLDB : on utilise int(1) avec DEFAULT. -->
<FIELD NAME="recommended" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
<!-- Le trio conventionnel d'audit, présent dans presque toutes les tables. -->
<FIELD NAME="usermodified" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
<FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
<FIELD NAME="timemodified" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
</FIELDS>
<KEYS>
<!-- Clé primaire : toujours nommée "primary", toujours sur id. -->
<KEY NAME="primary" TYPE="primary" FIELDS="id"/>
<!-- Clés étrangères DÉCLARATIVES : documentation + index, PAS de contrainte SGBD ! -->
<KEY NAME="courseid" TYPE="foreign" FIELDS="courseid" REFTABLE="course" REFFIELDS="id"/>
<KEY NAME="usermodified" TYPE="foreign" FIELDS="usermodified" REFTABLE="user" REFFIELDS="id"/>
</KEYS>
<INDEXES>
<!-- Index non-unique pour accélérer les recherches par ISBN. -->
<INDEX NAME="isbn" UNIQUE="false" FIELDS="isbn"/>
<!-- Index composé unique : un même titre ne peut exister qu'une fois par cours. -->
<INDEX NAME="coursetitle" UNIQUE="true" FIELDS="courseid, title"/>
</INDEXES>
</TABLE>
<TABLE NAME="local_bookshelf_loans" COMMENT="Loan tracking for recommended books.">
<FIELDS>
<FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
<FIELD NAME="bookid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
<FIELD NAME="userid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
<FIELD NAME="timeloaned" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
<FIELD NAME="timereturned" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false"/>
</FIELDS>
<KEYS>
<KEY NAME="primary" TYPE="primary" FIELDS="id"/>
<KEY NAME="bookid" TYPE="foreign" FIELDS="bookid" REFTABLE="local_bookshelf_books" REFFIELDS="id"/>
<!-- foreign-unique : FK déclarative + index unique en un seul élément.
Ici : un utilisateur donné ne peut apparaître qu'une fois (exemple
typique : table d'extension 1-1 de la table user). -->
<KEY NAME="userid" TYPE="foreign" FIELDS="userid" REFTABLE="user" REFFIELDS="id"/>
</KEYS>
<INDEXES>
<INDEX NAME="bookuser" UNIQUE="true" FIELDS="bookid, userid"/>
</INDEXES>
</TABLE>
</TABLES>
</XMLDB>Quelques observations structurantes. D’abord, remarquez qu’il n’y a aucun type date/datetime : Moodle stocke tous les instants en timestamps Unix dans des int(10) (timecreated, timemodified, timeloaned…). C’est un choix historique de portabilité (les types dates divergeaient énormément entre SGBD) qui a un avantage réel : les comparaisons et les tris sont triviaux et identiques partout. Le formatage en date lisible se fait côté PHP avec userdate(), qui gère le fuseau horaire de l’utilisateur.
Ensuite, il n’y a pas de type booléen : on utilise int(1) avec des valeurs 0/1. Enfin, il n’y a pas de type JSON natif : les données semi-structurées sont sérialisées en JSON dans des champs text (le core le fait lui-même, par exemple dans la colonne other des logs).
2.3 Les types de champs XMLDB
XMLDB ne connaît que six types, volontairement minimalistes :
| Type XMLDB | Attributs | Correspondance typique | Usage |
|---|---|---|---|
int | LENGTH (1 à 20) | TINYINT/INT/BIGINT | Entiers, booléens (int 1), timestamps et FK (int 10) |
char | LENGTH (1 à 1333) | VARCHAR(n) | Chaînes courtes (noms, codes, emails) |
text | — (pas de LENGTH) | TEXT / LONGTEXT / CLOB | Contenus longs (descriptions, HTML, JSON) |
number | LENGTH + DECIMALS | DECIMAL(l, d) / NUMERIC | Valeurs exactes (notes, montants) |
float | LENGTH/DECIMALS optionnels | FLOAT/DOUBLE | Valeurs approchées (rare ; préférez number) |
binary | — | BLOB | À éviter : les fichiers vont dans l’API Files (chapitre 05), jamais en BLOB |
Le LENGTH d’un int n’est pas une taille de stockage mais un nombre de chiffres indicatif, qui détermine le type SQL choisi : int(10) devient BIGINT sur la plupart des SGBD — c’est pourquoi toutes les colonnes id et FK sont des int(10). Les attributs transverses :
NOTNULL(true/false) : contrainte NOT NULL, réellement appliquée par le SGBD, elle.SEQUENCE(trueuniquement surid) : auto-incrément (AUTO_INCREMENT,IDENTITY, séquence PostgreSQL… selon le SGBD). Un champSEQUENCE="true"est implicitement NOT NULL et doit porter la clé primaire.DEFAULT: valeur par défaut. Convention forte : tout champNOTNULL="true"qui n’est pas la séquence devrait avoir unDEFAULT, sinon les upgrades sur des tables déjà remplies échouent (impossible d’ajouter une colonne NOT NULL sans défaut sur une table non vide).DECIMALS: nombre de décimales pournumber/float.COMMENT: documentation embarquée, fortement encouragée.
2.4 Les clés — et le grand piège des foreign keys
XMLDB définit quatre types de clés :
primary: la clé primaire, toujours surid, toujours présente, toujours nomméeprimary.unique: contrainte d’unicité (réellement créée comme index unique).foreign: clé étrangère… déclarative.foreign-unique: clé étrangère déclarative + unicité (pour les relations 1-1).
⚠️ Piège (probablement LE piège XMLDB le plus important) : les clés étrangères XMLDB ne sont PAS appliquées par le SGBD. Aucun
FOREIGN KEY ... REFERENCESn’est généré, aucune intégrité référentielle n’est vérifiée par la base, aucunON DELETE CASCADEn’existe. Une cléforeignen XMLDB produit : ① de la documentation lisible par les outils et les humains, ② un index non-unique sur la colonne (bien pratique pour les jointures). C’est tout. Si un cours est supprimé, c’est à votre code PHP de nettoyer vos lignes orphelines — typiquement via un observer sur l’événementcourse_deletedou via le hook de désinstallation. Ce choix date d’une époque où MyISAM (sans support des FK) dominait, et il a été conservé pour garder le contrôle applicatif total sur les suppressions. Ne comptez jamais sur la base pour empêcher une incohérence.
💡 Pour un dev React : c’est comme si toutes vos
relationsDrizzle ou vos@relationPrisma existaient dans le schéma pour le typage et les jointures, mais que la migration générée omettait systématiquement les contraintesREFERENCES. Le confort déclaratif sans le filet de sécurité. Prenez le réflexe : chaquedelete_records()sur une table « parente » doit s’accompagner du nettoyage manuel des tables « enfants », idéalement dans une transaction (section 8).
2.5 Les index
Les index (<INDEX>) sont, eux, bien réels. UNIQUE="true" crée un index unique (donc une vraie contrainte d’unicité), UNIQUE="false" un index de performance classique. FIELDS accepte une liste de colonnes pour les index composés. Règles pratiques :
- Ne créez pas d’index sur une colonne déjà couverte par une clé (la clé
foreigncrée déjà son index) — l’éditeur XMLDB vous le refusera d’ailleurs. - Indexez ce que vous filtrez et joignez réellement : les colonnes de vos
WHEREfréquents. - On ne peut pas indexer directement une colonne
text(limites cross-DB) — si vous devez chercher dedans, prévoyez une colonnechardénormalisée ou repensez le modèle.
2.6 L’éditeur XMLDB intégré : le workflow réel
On n’écrit pas install.xml à la main. Moodle embarque un éditeur dédié : Administration du site → Développement → XMLDB editor (visible quand vous êtes admin ; le plugin doit être présent sur le disque et son dossier db/ accessible en écriture par le serveur web).
Le workflow complet pour ajouter une table à votre plugin :
- Créez un dossier
db/vide dans votre plugin (ou avec uninstall.xmlminimal). - Ouvrez l’éditeur XMLDB : il liste tous les répertoires
db/du site. Cliquez [Load] sur celui de votre plugin, puis [Edit]. - Créez la table (New table), ajoutez les champs un à un : l’interface vous impose les bonnes pratiques (le champ
idavec séquence est créé d’office, les attributs invalides sont grisés). - Ajoutez les clés puis les index dans leurs onglets respectifs.
- [Save] : l’éditeur réécrit
install.xmlproprement (ordre canonique des attributs, checksums). - Et voici la fonctionnalité tueuse : [View PHP code] génère automatiquement le code PHP d’upgrade correspondant à vos modifications (le
create_table,add_field, etc. de la section 3), prêt à coller dansdb/upgrade.php.
L’éditeur propose aussi des rapports de cohérence globaux (« check indexes », « check foreign keys », « check bigints ») qui comparent le XML de tous les plugins avec la base réelle — très utile pour diagnostiquer un site dont les upgrades ont dérapé.
⚠️ Piège : l’éditeur XMLDB modifie le fichier
install.xmlsur le disque, mais ne touche jamais à la base de données du site sur lequel vous cliquez. Sauvegarder dans l’éditeur ne crée aucune table ! Pour que la table existe réellement, il faut soit désinstaller/réinstaller le plugin (dev uniquement), soit passer par un upgrade (section 3). Beaucoup de débutants sauvegardent dans l’éditeur, ne voient pas leur table, et cherchent le bug au mauvais endroit.
2.7 Conventions de nommage
Les conventions Moodle sont strictes et vérifiées par les outils de CI (moodle-plugin-ci) :
- Tables : préfixées par le nom frankenstyle du plugin… sauf pour les modules d’activité, qui omettent le préfixe
mod_. Le module Quiz utilise doncquiz,quiz_attempts,quiz_grades; mais un plugin local s’appellelocal_bookshelf_books, un blocblock_meteo_cities, un rapportreport_stats_daily. Attention à la limite historique de longueur des noms de tables (28 caractères, héritage Oracle encore vérifié par les linters) : soyez concis. - Colonnes : en minuscules, sans underscore de préférence (
timecreatedet nontime_created,courseidet noncourse_id), sans mots réservés SQL. Uniquementa-z0-9. id: obligatoire,int(10), séquence, clé primaire. Sans exception. Tout le DML repose dessus (lesget_records()indexent parid,update_record()l’exige).- Le trio d’audit :
usermodified(FK versuser),timecreated,timemodifiedsont attendus dans quasiment toute table métier. La classecore\persistentles gère automatiquement, et les reviewers les réclameront. - FK : nommées
<table>id(courseid,userid,bookid) — c’est ce qui rend les jointures lisibles dans tout l’écosystème.
📚 Aller plus loin : XMLDB documentation sur moodledev.io décrit le format complet, l’éditeur et les règles de compatibilité inter-SGBD, dont la page « XMLDB column types » qui détaille le mapping type XMLDB → type SQL pour chaque driver.
3. version.php et les upgrades
3.1 version.php décortiqué
Chaque plugin possède un fichier version.php à sa racine. C’est sa carte d’identité, et surtout le déclencheur du mécanisme d’upgrade :
<?php
// local/bookshelf/version.php
defined('MOODLE_INTERNAL') || die(); // Garde-fou : interdit l'accès direct par URL.
// Le numéro de version du PLUGIN, au format YYYYMMDDXX :
// année, mois, jour, puis un compteur à 2 chiffres pour les
// versions multiples d'une même journée (00, 01, 02...).
// C'est un ENTIER : Moodle compare numériquement, donc il doit
// strictement croître à chaque release.
$plugin->version = 2026070800;
// La version MINIMALE de Moodle requise (le $version du version.php
// racine de Moodle). Ici : la version de Moodle 5.2.
$plugin->requires = 2026042100;
// Le nom frankenstyle complet — DOIT correspondre au chemin du plugin
// (type "local" + dossier "bookshelf"). Moodle vérifie la cohérence
// et refuse l'installation sinon.
$plugin->component = 'local_bookshelf';
// Maturité : MATURITY_ALPHA, MATURITY_BETA, MATURITY_RC, MATURITY_STABLE.
// Affiché dans l'admin ; un site peut refuser d'installer du non-stable.
$plugin->maturity = MATURITY_STABLE;
// Version « humaine », libre, affichée dans l'interface d'administration.
$plugin->release = 'v1.4.0';
// Fourchette de branches Moodle supportées [min, max] (ici : 5.2 uniquement).
$plugin->supported = [502, 502];
// Dépendances vers d'autres plugins : composant => version minimale
// (ANY_VERSION pour "peu importe"). Moodle bloque l'installation et
// ordonne les upgrades en conséquence.
$plugin->dependencies = [
'mod_forum' => 2026042100,
'local_library' => ANY_VERSION,
];3.2 Le mécanisme de détection : config_plugins
Comment Moodle sait-il qu’un plugin doit être installé ou mis à jour ? À chaque affichage d’une page d’administration (et à chaque exécution de admin/cli/upgrade.php), Moodle compare deux nombres pour chaque plugin :
- le
$plugin->versiondu fichierversion.phpsur le disque ; - la version enregistrée en base, stockée dans la table
config_plugins(ligneplugin = 'local_bookshelf', name = 'version').
Trois cas : pas de ligne en base → le plugin est neuf, Moodle exécute install.xml puis db/install.php ; version disque > version base → upgrade nécessaire, Moodle exécute db/upgrade.php en lui passant la version en base ; version disque < version base → erreur fatale (on ne « downgrade » jamais un Moodle).
💡 Pour un dev React :
config_plugins.versionjoue le rôle de la table_prisma_migrations: c’est le marqueur de « jusqu’où les migrations ont été appliquées ». La grosse différence conceptuelle : Prisma stocke une ligne par migration appliquée, Moodle stocke un seul entier par plugin — d’où l’importance capitale des savepoints (section 3.5) qui font progresser cet entier étape par étape à l’intérieur du script d’upgrade.
3.3 db/upgrade.php : les migrations de Moodle
Quand une mise à jour est détectée, Moodle appelle une fonction au nom imposé : xmldb_<frankenstyle>_upgrade($oldversion) (pour un module : xmldb_quiz_upgrade ; pour notre plugin : xmldb_local_bookshelf_upgrade). Elle reçoit la version actuellement en base et doit dérouler toutes les étapes intermédiaires jusqu’à la version du disque. Voici un upgrade.php complet, commenté, qui illustre tous les patterns classiques :
<?php
// local/bookshelf/db/upgrade.php
defined('MOODLE_INTERNAL') || die();
/**
* Fonction d'upgrade de local_bookshelf.
*
* @param int $oldversion La version actuellement installée (lue dans config_plugins).
* @return bool Toujours true (les erreurs sont signalées par exception).
*/
function xmldb_local_bookshelf_upgrade($oldversion): bool {
global $DB;
// Le database_manager : l'objet qui sait générer et exécuter le DDL
// (CREATE/ALTER/DROP) de façon portable. C'est le pendant "schéma"
// de $DB, qui lui ne touche qu'aux données.
$dbman = $DB->get_manager();
// ---- Étape 1 : version 2026030400 a introduit la table des emprunts. ----
if ($oldversion < 2026030400) {
// On reconstruit la définition de la table EN PHP, miroir exact
// de ce qui a été ajouté dans install.xml. Ce code est généré
// par l'éditeur XMLDB ([View PHP code]) — ne l'écrivez pas à la main.
$table = new xmldb_table('local_bookshelf_loans');
$table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null);
$table->add_field('bookid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
$table->add_field('userid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
$table->add_field('timeloaned', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0');
$table->add_field('timereturned', XMLDB_TYPE_INTEGER, '10', null, null, null, null);
$table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']);
$table->add_key('bookid', XMLDB_KEY_FOREIGN, ['bookid'], 'local_bookshelf_books', ['id']);
$table->add_key('userid', XMLDB_KEY_FOREIGN, ['userid'], 'user', ['id']);
$table->add_index('bookuser', XMLDB_INDEX_UNIQUE, ['bookid', 'userid']);
// Idempotence : on ne crée la table que si elle n'existe pas déjà.
// Indispensable pour survivre à un upgrade interrompu puis relancé.
if (!$dbman->table_exists($table)) {
$dbman->create_table($table);
}
// LE savepoint : enregistre en base "cette étape est faite".
// Signature : (true, version_de_l_etape, type_du_plugin, nom_du_plugin).
upgrade_plugin_savepoint(true, 2026030400, 'local', 'bookshelf');
}
// ---- Étape 2 : version 2026041500 ajoute la colonne price. ----
if ($oldversion < 2026041500) {
$table = new xmldb_table('local_bookshelf_books');
// Définition du champ. Le dernier argument de add_field() dans
// l'objet xmldb_field est le champ APRÈS lequel insérer la colonne
// (purement cosmétique, ignoré par certains SGBD).
$field = new xmldb_field('price', XMLDB_TYPE_NUMBER, '10, 2', null, null, null, null, 'summaryformat');
if (!$dbman->field_exists($table, $field)) {
$dbman->add_field($table, $field);
}
upgrade_plugin_savepoint(true, 2026041500, 'local', 'bookshelf');
}
// ---- Étape 3 : version 2026052000 — isbn passe de char(13) à char(20),
// et on ajoute un index dessus. ----
if ($oldversion < 2026052000) {
$table = new xmldb_table('local_bookshelf_books');
$field = new xmldb_field('isbn', XMLDB_TYPE_CHAR, '20', null, null, null, null, 'title');
// change_field_precision pour la taille ; il existe aussi
// change_field_type, change_field_notnull, change_field_default.
$dbman->change_field_precision($table, $field);
$index = new xmldb_index('isbn', XMLDB_INDEX_NOTUNIQUE, ['isbn']);
if (!$dbman->index_exists($table, $index)) {
$dbman->add_index($table, $index);
}
upgrade_plugin_savepoint(true, 2026052000, 'local', 'bookshelf');
}
// ---- Étape 4 : version 2026070800 — migration de DONNÉES.
// Les upgrades ne font pas que du DDL : ici, on normalise les
// ISBN existants (suppression des tirets). ----
if ($oldversion < 2026070800) {
// Sur un gros volume, on utiliserait un recordset (section 4.6).
$books = $DB->get_records_select('local_bookshelf_books',
$DB->sql_like('isbn', ':pattern'), ['pattern' => '%-%']);
foreach ($books as $book) {
$DB->set_field('local_bookshelf_books', 'isbn',
str_replace('-', '', $book->isbn), ['id' => $book->id]);
}
upgrade_plugin_savepoint(true, 2026070800, 'local', 'bookshelf');
}
return true;
}3.4 Le database_manager : l’arsenal DDL
$dbman = $DB->get_manager() expose toutes les opérations de schéma. Les plus utilisées :
// Tables.
$dbman->table_exists($table); // Accepte un nom ou un objet xmldb_table.
$dbman->create_table($table);
$dbman->drop_table($table);
$dbman->rename_table($table, 'newname');
// Champs.
$dbman->field_exists($table, $field);
$dbman->add_field($table, $field);
$dbman->drop_field($table, $field);
$dbman->rename_field($table, $field, 'newname');
$dbman->change_field_type($table, $field); // ATTENTION : conversions de données à votre charge.
$dbman->change_field_precision($table, $field);
$dbman->change_field_notnull($table, $field);
$dbman->change_field_default($table, $field);
// Index et clés.
$dbman->index_exists($table, $index);
$dbman->add_index($table, $index);
$dbman->drop_index($table, $index);
$dbman->add_key($table, $key);
$dbman->drop_key($table, $key);Chacune de ces méthodes génère le bon DDL pour le SGBD courant, gère les cas tordus (renommer une colonne sous un vieux MySQL exigeait de redonner tout le type, SQL Server a ses propres rites pour les défauts…), et vous n’avez jamais à y penser.
3.5 Les savepoints : pourquoi ils sont vitaux
upgrade_plugin_savepoint(true, $version, $type, $name) fait deux choses : il met à jour config_plugins avec la version de l’étape, et il vide certains caches. Pourquoi est-ce si important ? Parce que le DDL n’est pas transactionnel de façon fiable sur tous les SGBD (MySQL commite implicitement à chaque ALTER TABLE). Si votre upgrade plante à l’étape 3 sur 4 :
- avec savepoints : la base enregistre que les étapes 1 et 2 sont faites. Au prochain lancement,
$oldversionvaut la version de l’étape 2, et seuls les blocs 3 et 4 se rejouent. - sans savepoints (un seul enregistrement de version à la toute fin) : tout l’upgrade se rejouerait depuis le début, et le
create_tablede l’étape 1 exploserait sur une table déjà existante (d’où, aussi, la ceinture-bretelles desif (!$dbman->table_exists(...))).
⚠️ Piège : le numéro passé au savepoint doit être exactement celui du
if ($oldversion < ...)qui l’entoure, et le dernier savepoint doit correspondre au$plugin->versiondeversion.php. Un savepoint avec un numéro supérieur à une étape ultérieure la ferait sauter silencieusement pour les sites qui s’upgradent en plusieurs fois — bug de migration vicieux, quasi impossible à diagnostiquer après coup. Pour le core, la fonction équivalente estupgrade_main_savepoint(), et pour les modulesupgrade_mod_savepoint().
3.6 install.php vs upgrade.php
À côté d’install.xml (schéma) et upgrade.php (évolutions), un troisième fichier optionnel existe : db/install.php. Il contient une fonction xmldb_local_bookshelf_install() exécutée une seule fois, juste après la création des tables, lors d’une installation fraîche. Son rôle : insérer des données initiales (catégories par défaut, réglages…), pas de DDL.
La logique de répartition est importante à intérioriser : une installation fraîche exécute install.xml + install.php et enregistre directement la version finale — elle n’exécute jamais upgrade.php. Un site existant exécute uniquement upgrade.php. Les deux chemins doivent donc aboutir au même état final :
⚠️ Piège : la règle cardinale — ne modifiez jamais
install.xmlsans écrire l’étapeupgrade.phpcorrespondante (et réciproquement). Si vous ajoutez une colonne dansinstall.xmlseulement, les nouvelles installations l’auront mais tous les sites existants exploseront avec des erreurs « column not found ». Si vous l’ajoutez dansupgrade.phpseulement, l’inverse. Testez systématiquement les DEUX chemins : ① installation fraîche du plugin, ② upgrade depuis la version précédente (gardez un dump de base « ancienne version » sous la main, ou utilisezphp admin/cli/upgrade.phpaprès un checkout git de l’ancienne version). L’éditeur XMLDB et son « View PHP code » aident, mais c’est votre discipline qui garantit la cohérence.
📚 Aller plus loin : la page Upgrade API sur moodledev.io détaille l’ordre d’exécution complet (core d’abord, puis plugins par ordre de dépendances) et les fonctions utilitaires disponibles pendant un upgrade (
upgrade_set_timeout()pour les migrations longues, notamment).
4. DML — lire des données
Nous voilà dans le quotidien absolu du développeur Moodle. Toutes les méthodes de lecture suivent une logique nominale cohérente : get_record* retourne une ligne, get_records* un tableau de lignes, get_field* une valeur scalaire, count_records* un entier, record_exists* un booléen, get_recordset* un itérateur. Et chaque famille se décline en trois saveurs : la version « conditions simples » (tableau clé/valeur, égalités uniquement), la version _select (fragment de WHERE paramétré) et la version _sql (requête complète).
4.1 get_record() : une ligne, avec gestion de la strictness
global $DB;
// Forme la plus courante : conditions en tableau associatif (égalités AND).
$user = $DB->get_record('user', ['id' => $userid]);
// → stdClass avec toutes les colonnes, ou FALSE si introuvable.
// Troisième paramètre : ne rapatrier que certaines colonnes (bonne pratique
// systématique sur les grosses tables comme user).
$user = $DB->get_record('user', ['id' => $userid], 'id, firstname, lastname, email');
// Quatrième paramètre : la "strictness", qui pilote le comportement d'erreur.
$user = $DB->get_record('user', ['id' => $userid], '*', MUST_EXIST);Les trois niveaux de strictness :
IGNORE_MISSING(défaut) : retournefalsesi aucune ligne ; si plusieurs lignes correspondent, retourne la première et loggue un avertissement en mode développeur (c’est un signal de bug !).MUST_EXIST: lève unedml_missing_record_exceptionsi aucune ligne. Utilisez-le partout où l’absence est un état anormal — c’est plus propre que de testerfalsepuis lancer votre propre erreur, et le message d’exception est standardisé.IGNORE_MULTIPLE: retourne la première ligne sans broncher même s’il y en a plusieurs. À réserver aux cas où le doublon est structurellement possible et acceptable.
// La variante SQL, pour les jointures. Une seule ligne attendue !
$sql = "SELECT c.id, c.fullname, cc.name AS categoryname
FROM {course} c
JOIN {course_categories} cc ON cc.id = c.category
WHERE c.id = :courseid";
$course = $DB->get_record_sql($sql, ['courseid' => $courseid], MUST_EXIST);💡 Pour un dev React :
get_record(..., MUST_EXIST)↔prisma.user.findUniqueOrThrow(),get_record()tout court ↔findFirst()(qui retournenull— icifalse, PHP oblige). Et comme avec Prisma, sélectionner explicitement ses colonnes (select: {...}) plutôt que tout rapatrier est un réflexe de performance, surtout que le DML n’a pas de lazy loading : chaquestdClassest entièrement hydraté.
4.2 get_records() et ses variantes : plusieurs lignes
// Signature : get_records($table, $conditions, $sort, $fields, $limitfrom, $limitnum)
$books = $DB->get_records(
'local_bookshelf_books',
['courseid' => $courseid, 'recommended' => 1], // WHERE courseid=? AND recommended=?
'title ASC', // ORDER BY
'id, title, isbn', // SELECT (défaut : *)
0, // $limitfrom (OFFSET)
20 // $limitnum (LIMIT)
);
foreach ($books as $id => $book) {
// $id est la clé du tableau : la valeur de la colonne id.
echo format_string($book->title);
}Le détail crucial : le tableau retourné est indexé par la première colonne du SELECT (par défaut id). C’est délicieusement pratique (isset($books[42]), fusion de résultats par id…), mais c’est aussi une source de bugs :
⚠️ Piège : dans
get_records_sql(), si la première colonne de votre SELECT n’est pas unique, les lignes en doublon s’écrasent silencieusement dans le tableau (et le mode développeur affiche un warning « Did you remember to make the first column something unique… »). Requête qui jointuseretrole_assignmentsen commençant parSELECT u.id: un utilisateur avec deux rôles n’apparaîtra qu’une fois, avec des données du dernier écrasement. Solutions : mettre en première colonne quelque chose d’unique (l’id de la table la plus « fine », ou unra.id), ou construire une colonne synthétique unique. Vérifiez ce point sur chaqueget_records_sqlque vous écrivez.
Les variantes :
// _select : vous écrivez le WHERE (paramétré !), Moodle fait le reste.
$overdue = $DB->get_records_select('local_bookshelf_loans',
'timereturned IS NULL AND timeloaned < :limit',
['limit' => time() - (30 * DAYSECS)],
'timeloaned ASC');
// _sql : requête complète, avec pagination en paramètres PHP (jamais LIMIT dans le SQL !).
$sql = "SELECT b.id, b.title, COUNT(l.id) AS loancount
FROM {local_bookshelf_books} b
LEFT JOIN {local_bookshelf_loans} l ON l.bookid = b.id
WHERE b.courseid = :courseid
GROUP BY b.id, b.title
ORDER BY loancount DESC";
$top = $DB->get_records_sql($sql, ['courseid' => $courseid], 0, 10);
// _list : WHERE colonne IN (liste de valeurs).
$somebooks = $DB->get_records_list('local_bookshelf_books', 'id', [3, 7, 42], 'title');
// _menu : retourne un tableau associatif première_colonne => deuxième_colonne.
// Parfait pour alimenter un <select> de formulaire.
$menu = $DB->get_records_menu('local_bookshelf_books',
['courseid' => $courseid], 'title', 'id, title');
// → [3 => 'Clean Code', 7 => 'Refactoring', ...]
// Et sa version SQL libre :
$menu = $DB->get_records_sql_menu(
"SELECT id, " . $DB->sql_fullname() . " AS fullname FROM {user} WHERE deleted = 0");4.3 Valeurs scalaires : get_field*
// Une seule valeur d'une seule ligne.
$title = $DB->get_field('local_bookshelf_books', 'title', ['id' => $bookid], MUST_EXIST);
// Version _select et _sql.
$max = $DB->get_field_sql("SELECT MAX(timeloaned) FROM {local_bookshelf_loans} WHERE userid = :u",
['u' => $userid]);
// Une colonne entière (toutes les lignes) sous forme de tableau plat :
$bookids = $DB->get_fieldset_select('local_bookshelf_loans', 'bookid',
'userid = :userid AND timereturned IS NULL', ['userid' => $userid]);
// → [3, 7, 42] — idéal à combiner avec get_in_or_equal() (section 7).4.4 Compter et tester l’existence
$total = $DB->count_records('local_bookshelf_books', ['courseid' => $courseid]);
$active = $DB->count_records_select('local_bookshelf_loans', 'timereturned IS NULL');
$distinct = $DB->count_records_sql(
"SELECT COUNT(DISTINCT userid) FROM {local_bookshelf_loans} WHERE bookid = :b",
['b' => $bookid]);
if ($DB->record_exists('local_bookshelf_loans', ['bookid' => $bookid, 'userid' => $userid])) {
// record_exists est plus efficace et plus expressif que count(...) > 0.
throw new moodle_exception('alreadyloaned', 'local_bookshelf');
}
// Existe aussi : record_exists_select() et record_exists_sql().4.5 get_recordset* : le streaming des gros volumes
get_records() charge tout le résultat en mémoire d’un coup. Sur la table user d’une université (200 000 lignes) ou logstore_standard_log (des dizaines de millions), c’est l’explosion mémoire assurée. Pour ces cas, le DML offre les recordsets : des itérateurs qui streament les lignes une par une depuis un curseur serveur.
global $DB;
// Mêmes signatures que get_records / get_records_select / get_records_sql.
$rs = $DB->get_recordset_select('user',
'deleted = 0 AND lastaccess > :since',
['since' => time() - YEARSECS],
'lastaccess DESC',
'id, username, email, lastaccess');
foreach ($rs as $user) {
// Une seule ligne en mémoire à la fois : on peut traiter des millions
// d'enregistrements avec une empreinte mémoire constante.
local_bookshelf_send_reminder($user);
}
// OBLIGATOIRE, TOUJOURS, MÊME EN CAS D'EXCEPTION POTENTIELLE :
$rs->close();Trois différences comportementales avec get_records() à bien avoir en tête : ① un recordset n’est pas un tableau (pas de count(), pas d’accès $rs[42], pas d’indexation par id) ; ② il ne se parcourt qu’une fois ; ③ pour tester s’il contient au moins une ligne, utilisez $rs->valid() avant d’itérer.
⚠️ Piège : oublier
$rs->close()laisse un curseur ouvert côté SGBD. Sur MySQL avec les résultats non bufferisés, cela peut carrément bloquer toute requête suivante (« Commands out of sync ») ; sur PostgreSQL, cela consomme des ressources serveur jusqu’à la fin du script. Si le corps de votre boucle peut lever une exception, enveloppez avectry { ... } finally { $rs->close(); }. Le mode développeur de Moodle vous signale d’ailleurs les recordsets non fermés en fin de requête HTTP — activez-le toujours en local ($CFG->debug = E_ALL; $CFG->debugdeveloper = true;).
💡 Pour un dev React :
get_records()vsget_recordset()=findMany()vs un curseur/stream. En Prisma vous n’avez longtemps eu que la pagination parcursorpour émuler ça ; ici c’est un vrai streaming au niveau du driver, l’équivalent dustream()de Drizzle ou d’unAsyncIteratorsur une requête. Règle simple : au-delà de quelques milliers de lignes potentielles, ou dès que le volume dépend des données du site (tâches cron, migrations), c’est recordset obligatoire.
5. DML — écrire des données
5.1 insert_record() : créer
global $DB, $USER;
// On construit un stdClass (jamais de tableau ici — convention forte).
$book = new stdClass();
$book->courseid = $courseid;
$book->title = 'Domain-Driven Design';
$book->isbn = '9780321125217';
$book->summary = '<p>Le classique d\'Eric Evans.</p>';
$book->summaryformat = FORMAT_HTML;
$book->price = 54.90;
$book->recommended = 1;
$book->usermodified = $USER->id;
$book->timecreated = time();
$book->timemodified = time();
// insert_record retourne l'id généré (comportement par défaut, $returnid = true).
$book->id = $DB->insert_record('local_bookshelf_books', $book);Points de vigilance : ne définissez jamais $book->id avant l’insertion (la séquence s’en charge) ; toute propriété de l’objet qui ne correspond à aucune colonne déclenche une erreur en mode strict (le DML vérifie les colonnes via son cache de métadonnées) ; les valeurs null sont acceptées pour les colonnes nullables.
Pour les insertions en masse, insert_records() prend un tableau (ou un itérable — on peut lui passer un generator !) d’objets et les insère par lots optimisés. Contrepartie : il ne retourne pas les ids et tous les objets doivent avoir exactement les mêmes propriétés :
$rows = [];
foreach ($isbnlist as $isbn) {
$row = new stdClass();
$row->courseid = $courseid;
$row->title = local_bookshelf_lookup_title($isbn);
$row->isbn = $isbn;
$row->summaryformat = FORMAT_HTML;
$row->recommended = 0;
$row->usermodified = $USER->id;
$row->timecreated = $row->timemodified = time();
$rows[] = $row;
}
$DB->insert_records('local_bookshelf_books', $rows); // Bulk, pas d'ids retournés.5.2 update_record() et set_field() : modifier
// update_record : l'objet DOIT contenir un id ; seules les propriétés
// présentes sont mises à jour (pas besoin de recharger toute la ligne).
$update = new stdClass();
$update->id = $bookid;
$update->recommended = 0;
$update->timemodified = time();
$DB->update_record('local_bookshelf_books', $update);
// set_field : mise à jour d'UNE colonne pour toutes les lignes matchant
// les conditions — un UPDATE ciblé sans charger quoi que ce soit.
$DB->set_field('local_bookshelf_loans', 'timereturned', time(),
['bookid' => $bookid, 'userid' => $userid]);
// set_field_select : même chose avec un WHERE libre paramétré.
$DB->set_field_select('local_bookshelf_books', 'recommended', 0,
'courseid = :courseid AND price > :maxprice',
['courseid' => $courseid, 'maxprice' => 100]);5.3 delete_records() : supprimer
// Par conditions d'égalité.
$DB->delete_records('local_bookshelf_loans', ['bookid' => $bookid]);
// ATTENTION : delete_records('table') SANS conditions vide TOUTE la table.
// C'est légal et parfois voulu (tables de cache), mais relisez-vous deux fois.
// Par WHERE libre.
$DB->delete_records_select('local_bookshelf_loans',
'timereturned IS NOT NULL AND timereturned < :cutoff',
['cutoff' => time() - (2 * YEARSECS)]);
// Par liste de valeurs (WHERE bookid IN (...)).
$DB->delete_records_list('local_bookshelf_loans', 'bookid', $obsoletebookids);5.4 execute() : le SQL brut d’écriture
Pour les UPDATE/DELETE complexes (avec jointures logiques, calculs…), execute() exécute du SQL arbitraire — toujours paramétré, toujours en syntaxe {table} :
// Marquer non-recommandés tous les livres des cours cachés.
$DB->execute("
UPDATE {local_bookshelf_books}
SET recommended = 0, timemodified = :now
WHERE courseid IN (SELECT id FROM {course} WHERE visible = 0)
", ['now' => time()]);execute() est le dernier recours : préférez toujours une méthode dédiée quand elle existe (elle invalide correctement les caches internes et reste lisible). Et bien sûr, jamais de DDL par execute() — c’est le travail exclusif du database_manager.
5.5 CRUD complet : une classe de service réaliste
Assemblons tout dans une petite classe de service comme on en écrit réellement dans un plugin local (namespace PSR-4 : local_bookshelf\, fichier classes/book_manager.php) :
<?php
namespace local_bookshelf;
/**
* Service CRUD pour les livres de local_bookshelf.
*/
class book_manager {
/**
* Crée un livre et retourne l'enregistrement complet.
*/
public static function create(int $courseid, string $title, ?string $isbn = null): \stdClass {
global $DB, $USER;
$record = new \stdClass();
$record->courseid = $courseid;
$record->title = $title;
$record->isbn = $isbn;
$record->summaryformat = FORMAT_HTML;
$record->recommended = 0;
$record->usermodified = $USER->id;
$record->timecreated = time();
$record->timemodified = time();
$record->id = $DB->insert_record('local_bookshelf_books', $record);
return $record;
}
/**
* Charge un livre (exception standardisée si absent).
*/
public static function get(int $id): \stdClass {
global $DB;
return $DB->get_record('local_bookshelf_books', ['id' => $id], '*', MUST_EXIST);
}
/**
* Liste paginée des livres d'un cours.
*
* @return \stdClass[] indexé par id.
*/
public static function list_for_course(int $courseid, int $page = 0, int $perpage = 25): array {
global $DB;
return $DB->get_records('local_bookshelf_books', ['courseid' => $courseid],
'title ASC', 'id, title, isbn, recommended, timemodified',
$page * $perpage, $perpage);
}
/**
* Met à jour le titre (et le trio d'audit, toujours).
*/
public static function rename(int $id, string $newtitle): void {
global $DB, $USER;
$update = new \stdClass();
$update->id = $id;
$update->title = $newtitle;
$update->usermodified = $USER->id;
$update->timemodified = time();
$DB->update_record('local_bookshelf_books', $update);
}
/**
* Supprime un livre ET ses données dépendantes.
* Rappel : pas de ON DELETE CASCADE dans Moodle — c'est ici que
* l'intégrité référentielle se joue, dans une transaction.
*/
public static function delete(int $id): void {
global $DB;
$transaction = $DB->start_delegated_transaction();
$DB->delete_records('local_bookshelf_loans', ['bookid' => $id]); // Enfants d'abord.
$DB->delete_records('local_bookshelf_books', ['id' => $id]); // Parent ensuite.
$transaction->allow_commit();
}
}💡 Pour un dev React : ce pattern « classe statique de service qui encapsule le DML » est l’équivalent de vos fichiers
lib/repositories/*.tsautour de Prisma. Moodle offre aussicore\persistent(une classe abstraite façon Active Record : vous déclarez les propriétés et leurs types, elle gère la validation,create(),update(), le trio d’audit automatiquement) — beaucoup de code moderne du core l’utilise. Mais sous le capot, c’est toujours ce DML-là, et vous devrez le lire couramment.
6. Les placeholders : requêtes paramétrées obligatoires
6.1 Deux syntaxes, un principe
Tout SQL contenant des valeurs variables doit être paramétré. Le DML accepte deux styles, définis par les constantes SQL_PARAMS_QM et SQL_PARAMS_NAMED :
// Style positionnel "?" (SQL_PARAMS_QM) : paramètres dans un tableau ordonné.
$sql = "SELECT * FROM {user} WHERE deleted = ? AND lastaccess > ?";
$users = $DB->get_records_sql($sql, [0, $since]);
// Style nommé ":name" (SQL_PARAMS_NAMED) : tableau associatif, ordre libre.
$sql = "SELECT * FROM {user} WHERE deleted = :deleted AND lastaccess > :since";
$users = $DB->get_records_sql($sql, ['deleted' => 0, 'since' => $since]);Règles : on ne mélange jamais les deux styles dans une même requête ; un placeholder nommé ne peut être utilisé qu’une seule fois dans la requête (si la même valeur sert deux fois, créez :since1 et :since2 et passez la valeur deux fois — limitation volontaire pour la portabilité) ; les noms sont limités aux caractères alphanumériques. Le style nommé est recommandé pour tout SQL non trivial : il survit aux réorganisations de la requête et se marie bien avec les helpers qui génèrent des placeholders (section 7). En interne, le DML convertit vers le style natif du driver — vous n’avez pas à y penser.
6.2 L’injection SQL, concrètement
// ❌ CATASTROPHE : concaténation directe d'une entrée utilisateur.
$search = required_param('search', PARAM_RAW);
$users = $DB->get_records_sql(
"SELECT * FROM {user} WHERE lastname = '" . $search . "'");
// Avec search = "x' OR '1'='1", la requête devient :
// SELECT * FROM mdl_user WHERE lastname = 'x' OR '1'='1'
// → dump complet de la table user. Avec "'; DROP TABLE mdl_user; --",
// c'est potentiellement pire selon le driver.
// ✅ CORRECT : la valeur voyage séparément du SQL, le SGBD ne peut
// pas l'interpréter comme du code.
$users = $DB->get_records_sql(
"SELECT * FROM {user} WHERE lastname = :search",
['search' => $search]);Notez que les méthodes à conditions simples (get_record('user', ['lastname' => $search])) paramètrent automatiquement — impossible de se tromper avec elles. Le danger n’existe que dans les familles _select et _sql, et dans execute().
6.3 Les limites des placeholders
Les placeholders ne transportent que des valeurs. Ils ne peuvent jamais représenter un nom de table, un nom de colonne, une direction de tri ou un mot-clé SQL :
// ❌ NE PEUT PAS FONCTIONNER : un placeholder n'est pas un identifiant.
$sql = "SELECT * FROM {course} ORDER BY :column :direction";
// ✅ Le pattern sûr : whitelist stricte côté PHP.
$allowedcolumns = ['fullname', 'shortname', 'startdate'];
$column = in_array($sortcolumn, $allowedcolumns, true) ? $sortcolumn : 'fullname';
$direction = ($sortdir === 'DESC') ? 'DESC' : 'ASC';
$sql = "SELECT id, fullname, shortname FROM {course} ORDER BY {$column} {$direction}";⚠️ Piège : le tri dynamique (colonne cliquée dans un tableau) est LE cas où des développeurs pourtant vigilants réintroduisent une injection, parce que « ce n’est qu’un ORDER BY ». Un
ORDER BY (CASE WHEN (SELECT ...) THEN 1 END)permet des injections en aveugle parfaitement exploitables. Toute chaîne interpolée dans du SQL doit provenir d’une liste blanche codée en dur, jamais d’une simple validation de format.
💡 Pour un dev React : c’est exactement la différence entre
prisma.$queryRaw\SELECT * FROM t WHERE x = ${v}`(tagged template → paramétré, sûr) et$queryRawUnsafe(”… ” + v)(concaténation, dangereux). Et la limite est la même :Prisma.raw()` pour les identifiants dynamiques exige aussi une whitelist. Les concepts se transposent un pour un.
7. Helpers SQL cross-DB
Dès qu’une construction SQL diverge entre SGBD, $DB expose une méthode sql_*() qui retourne le fragment SQL adapté au driver courant. On les concatène dans ses requêtes. Tour d’horizon des indispensables.
7.1 get_in_or_equal() : les clauses IN paramétrées
Construire un IN (?, ?, ?) avec le bon nombre de placeholders à la main est pénible et fragile. Ce helper le fait pour vous, et gère élégamment le cas à une seule valeur (il génère = ?) :
$courseids = [12, 15, 33];
// Style positionnel (défaut) :
[$insql, $params] = $DB->get_in_or_equal($courseids);
// $insql = "IN (?,?,?)"
// $params = [12, 15, 33]
$courses = $DB->get_records_select('course', "id $insql", $params);
// Style nommé, avec préfixe pour éviter les collisions de noms,
// indispensable dès qu'on combine avec d'autres paramètres nommés :
[$insql, $inparams] = $DB->get_in_or_equal($courseids, SQL_PARAMS_NAMED, 'cid');
// $insql = "IN (:cid1,:cid2,:cid3)", $inparams = ['cid1' => 12, ...]
$params = $inparams + ['visible' => 1];
$courses = $DB->get_records_select('course', "id $insql AND visible = :visible", $params);
// Négation : quatrième argument $equal = false → "NOT IN (...)" / "<> ?".
[$notinsql, $notinparams] = $DB->get_in_or_equal($excludedids, SQL_PARAMS_NAMED, 'ex', false);⚠️ Piège :
get_in_or_equal()avec un tableau vide lève une exception par défaut (une clauseIN ()est du SQL invalide). Testezempty($ids)avant l’appel et court-circuitez (retour anticipé de tableau vide), ou utilisez le cinquième paramètre$onemptyitemspour définir un comportement de repli. Ce crash « coding error detected » en production sur le cas limite « aucune sélection » est un grand classique des rapports de bugs de plugins.
7.2 sql_like() et sql_like_escape() : recherches insensibles à la casse
LIKE est sensible à la casse sous PostgreSQL, insensible sous MySQL : divergence type. sql_like($champ, $placeholder, $casesensitive, $accentsensitive, $notlike) génère la bonne construction (ILIKE, collations…) :
$search = optional_param('q', '', PARAM_RAW_TRIMMED);
// sql_like_escape échappe les jokers % et _ que l'utilisateur aurait tapés :
// sans lui, chercher "50%" matcherait "50" suivi de n'importe quoi.
$param = '%' . $DB->sql_like_escape($search) . '%';
$select = $DB->sql_like('title', ':search', false); // false = insensible à la casse.
$books = $DB->get_records_select('local_bookshelf_books', $select, ['search' => $param]);7.3 Concaténation et noms complets
// CONCAT / || / + : chaque SGBD a sa syntaxe. sql_concat unifie :
$fullref = $DB->sql_concat('b.title', "' ('", 'b.isbn', "')'");
// Les littéraux sont passés entre quotes simples DANS la chaîne PHP.
// Avec séparateur commun :
$joined = $DB->sql_concat_join("', '", ['u.city', 'u.country']);
// Le cas d'école : le nom complet d'un utilisateur en SQL.
$fullname = $DB->sql_fullname('u.firstname', 'u.lastname');
$sql = "SELECT u.id, {$fullname} AS fullname FROM {user} u WHERE u.deleted = 0";Nuance importante sur sql_fullname() : il produit un simple « prénom nom », suffisant pour un tri SQL, mais l’affichage d’un nom doit passer par la fonction PHP fullname($user) qui respecte le réglage de format de nom du site et les droits de voir les noms alternatifs. Le pattern habituel : trier en SQL avec sql_fullname(), afficher avec fullname().
7.4 Le cas épineux des colonnes TEXT
Héritage des SGBD historiques (Oracle et ses CLOB en tête) : on ne peut pas fiablement comparer, grouper ou trier sur une colonne text de façon portable. Deux helpers contournent :
// Comparer une colonne text à une valeur : sql_compare_text tronque et caste
// des deux côtés (par défaut 32 caractères — passez une taille suffisante !).
$select = $DB->sql_compare_text('summary') . ' = ' . $DB->sql_compare_text(':summary', 255);
$books = $DB->get_records_select('local_bookshelf_books', $select, ['summary' => $text]);
// Trier sur une colonne text :
$sql = "SELECT id, title, summary FROM {local_bookshelf_books}
ORDER BY " . $DB->sql_order_by_text('summary', 255);⚠️ Piège :
sql_compare_text()tronque à N caractères (32 par défaut) avant de comparer. Deux textes différant seulement après le 32e caractère seront considérés égaux ! Si vous comparez des contenus longs, passez explicitement une taille couvrant vos données, ou mieux : remettez en question le modèle (comparer des TEXT en SQL est presque toujours le symptôme d’une colonnecharde hash manquante).
7.5 Vides, casts et divers
// NULL vs chaîne vide : Oracle confondait les deux, et le core en garde
// des précautions. Pour tester "vide" de façon portable :
$select = $DB->sql_isempty('local_bookshelf_books', 'isbn', true, false);
// Arguments : table, colonne, la colonne est-elle nullable, est-elle de type text.
$noisbn = $DB->get_records_select('local_bookshelf_books', $select);
// Et son inverse : sql_isnotempty(...).
// Caster une colonne char contenant des nombres en entier (pour trier
// numériquement "10" après "9", ou joindre sur une colonne char) :
$sql = "SELECT * FROM {config_plugins}
WHERE plugin = :plugin AND name = 'version'
ORDER BY " . $DB->sql_cast_char2int('value');Citons encore, pour votre culture de lecture du core : sql_length(), sql_substr(), sql_position(), sql_bitand()/sql_bitor()/sql_bitxor() (les capabilities historiques utilisaient des masques de bits), et sql_group_concat() (ajouté dans les versions récentes — enfin une agrégation de chaînes portable).
7.6 Exemple de synthèse : une recherche complète
Combinons tout dans une vraie fonction de recherche comme on en écrit pour un rapport :
/**
* Recherche des livres par mot-clé dans un sous-ensemble de cours,
* avec le nom complet du dernier éditeur.
*
* @param string $query Texte cherché dans le titre ou l'ISBN.
* @param int[] $courseids Cours autorisés (déjà filtrés par capabilities !).
* @return \stdClass[]
*/
function local_bookshelf_search(string $query, array $courseids): array {
global $DB;
if (empty($courseids)) {
return []; // Court-circuit : get_in_or_equal refuse les tableaux vides.
}
// 1. La clause IN paramétrée, en style nommé avec préfixe.
[$insql, $params] = $DB->get_in_or_equal($courseids, SQL_PARAMS_NAMED, 'crs');
// 2. Le LIKE insensible à la casse, jokers échappés.
// Deux placeholders distincts pour la même valeur (règle des nommés !).
$likevalue = '%' . $DB->sql_like_escape($query) . '%';
$titlelike = $DB->sql_like('b.title', ':qtitle', false);
$isbnlike = $DB->sql_like('b.isbn', ':qisbn', false);
$params['qtitle'] = $likevalue;
$params['qisbn'] = $likevalue;
// 3. Le nom complet cross-DB pour le tri, l'affichage se fera avec fullname().
$editorname = $DB->sql_fullname('u.firstname', 'u.lastname');
$sql = "SELECT b.id, b.title, b.isbn, b.courseid,
c.shortname AS courseshortname,
{$editorname} AS editorname
FROM {local_bookshelf_books} b
JOIN {course} c ON c.id = b.courseid
LEFT JOIN {user} u ON u.id = b.usermodified
WHERE b.courseid {$insql}
AND ({$titlelike} OR {$isbnlike})
ORDER BY b.title ASC";
return $DB->get_records_sql($sql, $params, 0, 50);
}📚 Aller plus loin : la liste exhaustive des helpers avec leurs subtilités par driver est dans la section « SQL compatibility functions » de la page DML de moodledev.io, et le code source
lib/dml/moodle_database.phpest remarquablement bien commenté — le lire est un excellent investissement.
8. Transactions déléguées
8.1 Le pattern
global $DB;
$transaction = $DB->start_delegated_transaction();
try {
$loanid = $DB->insert_record('local_bookshelf_loans', $loan);
$DB->set_field('local_bookshelf_books', 'timemodified', time(), ['id' => $loan->bookid]);
local_bookshelf_notify_librarian($loan); // Peut lever une exception.
// Marque cette transaction comme "prête à committer". Le COMMIT réel
// n'a lieu que quand la transaction LA PLUS EXTERNE le permet aussi.
$transaction->allow_commit();
} catch (Throwable $e) {
// Rollback : annule tout ET relance l'exception. Le code après cette
// ligne n'est JAMAIS atteint (rollback() termine toujours par un throw).
$transaction->rollback($e);
}8.2 Pourquoi « déléguées » ?
Le qualificatif est la clé du design. Dans une application aussi modulaire que Moodle, votre fonction peut être appelée à l’intérieur d’une transaction déjà ouverte par quelqu’un d’autre (le cœur des inscriptions, une restauration de sauvegarde…). Les vraies transactions imbriquées n’existant dans aucun SGBD de façon portable, Moodle émule : si start_delegated_transaction() est appelé alors qu’une transaction est déjà en cours, il n’ouvre pas de nouvelle transaction SQL — il incrémente un compteur d’imbrication. Chaque niveau doit ensuite « voter » via allow_commit(), et seul le allow_commit() du niveau le plus externe déclenche le COMMIT réel. À l’inverse, un rollback() à n’importe quel niveau condamne l’ensemble : les niveaux supérieurs ne pourront plus committer (toute tentative lève une dml_transaction_exception), garantissant qu’un échec local ne laisse jamais un état partiel.
D’où le contrat : chaque start_delegated_transaction() doit aboutir soit à allow_commit(), soit à rollback($e). Une transaction abandonnée (fin de fonction sans l’un des deux) provoque un rollback automatique et une erreur en mode développeur.
8.3 Les pièges des transactions
⚠️ Piège n° 1 — pas de DDL : n’appelez jamais le
database_manager(create_table, add_field…) dans une transaction déléguée. Sur MySQL/MariaDB, tout DDL provoque un commit implicite qui casse la transaction en cours de façon sournoise ; Moodle vous en protège partiellement en levant une exception, mais ne jouez pas avec. Les upgrades gèrent ça eux-mêmes — encore une raison de laisser les savepoints faire leur travail.
⚠️ Piège n° 2 — tout n’est pas transactionnel : une transaction ne protège que les écritures SQL. L’envoi d’un email, l’écriture d’un fichier via l’API Files, un appel HTTP, la promotion d’un événement (chapitre 06)… rien de tout cela n’est annulé par un rollback. Déclenchez les effets de bord après le
allow_commit()du niveau externe quand c’est possible — c’est exactement pour ça que l’API d’événements bufferise les événements créés pendant une transaction et ne les dispatche qu’au commit. Note historique : sur les très vieux sites MySQL en MyISAM (moteur sans transactions), tout ce mécanisme était silencieusement inopérant ; c’est aujourd’hui hors sujet (InnoDB/Barracuda est exigé depuis longtemps), mais cela explique pourquoi le core, par tradition défensive, ne s’appuie jamais uniquement sur une transaction pour son intégrité.
💡 Pour un dev React :
start_delegated_transaction()/allow_commit()↔prisma.$transaction(async (tx) => {...}), avec la même sémantique « l’exception fait tout annuler ». La délégation/imbrication, en revanche, n’a pas d’équivalent direct chez Prisma (qui interdit d’imbriquer) : c’est plus proche desSAVEPOINTque certains ORM utilisent, sauf que Moodle choisit « tout ou rien » plutôt que des rollbacks partiels — plus simple à raisonner dans un écosystème de plugins.
9. Le schéma mental des tables cœur
Nous y voilà. Un Moodle fraîchement installé compte environ 500 tables. La bonne nouvelle : une quinzaine d’entre elles forment l’ossature que tout le reste décore, et une fois leur logique comprise, vous pouvez deviner le reste du schéma. Cette section est à relire jusqu’à ce que le diagramme ci-dessous vous semble évident.
9.1 La carte générale
┌────────────────┐
│ course_categories│ path: /1/3
│ (arbre: path, │◄──────────┐
│ depth, parent)│ │ category
└────────────────┘ ┌──────┴───────┐
│ course │
│ fullname, │
│ format, │
│ visible │
└──┬───────┬───┘
course │ │ │ course
┌──────────────────┘ │ └────────────────┐
▼ ▼ ▼
┌───────────────┐ sequence (CSV of cm ids) ┌──────────────────┐ ┌─────────────┐
│course_sections│◄─────────────────────────┤ course_modules │ │ enrol │
│ section, name │ section (FK) │ ★ TABLE PIVOT ★ │ │ (instances │
└───────────────┘ │ course, module, │ │ de méthode:│
│ instance, section,│ │ manual,self)│
┌─────────────────────────┤ visible, idnumber │ └──────┬──────┘
│ module (quel type ?) └─────────┬────────┘ │ enrolid
▼ │ instance ▼
┌─────────────┐ ▼ ┌───────────────┐
│ modules │ ┌──────────────┐ │user_enrolments│
│ name='quiz' │ │ quiz / forum │ │ userid, status│
└─────────────┘ │ / page / ... │ │ timestart/end │
│ (1 table par │ └───────┬───────┘
│ type de mod)│ │ userid
└──────────────┘ ▼
┌───────────┐ instanceid (selon contextlevel) ┌──────────┐
│ context │◄───── pointe vers course.id (50), course_modules.id (70), │ user │
│ level 10..80 │ user.id (30), course_categories.id (40)... │ deleted, │
│ path /1/25/318│ │ suspended│
└─────┬─────┘ └────┬─────┘
│ contextid │ userid
▼ │
┌────────────────┐ roleid ┌──────┐ │
│role_assignments│────────────────────────►│ role │ │
│ userid ─────────────────────────────────────────────────────────────────────┘
└────────────────┘ └──┬───┘
│ roleid
▼
┌──────────────────┐ ┌──────────────┐
│role_capabilities │─────►│ capabilities │
│ contextid, perm │ │ mod/quiz:view│
└──────────────────┘ └──────────────┘
Autour de user gravitent aussi : grade_grades (via grade_items→course),
groups_members (via groups→course), cohort_members (via cohort),
files (via contextid), logstore_standard_log (userid + contextid + courseid).Retenez la lecture en trois axes : l’axe structure (catégories → cours → sections → modules), l’axe personnes (user → inscriptions → rôles), et l’axe transversal context, qui relie les deux pour les permissions. Détaillons.
9.2 user : les personnes
La table user contient tous les comptes : étudiants, enseignants, admins — le rôle n’est pas une propriété de l’utilisateur (voir 9.6). Champs à connaître :
username,auth(le plugin d’authentification :manual,ldap,oauth2…),password(hash bcrypt, vide pour les auth externes),email,firstname,lastname.confirmed: compte validé (workflow d’auto-inscription).deleted: Moodle ne supprime jamais physiquement un utilisateur (trop de FK déclaratives pointent vers lui). La « suppression » metdeleted = 1et anonymise username/email. Toute requête suruserdoit filtrerdeleted = 0— l’oublier est l’erreur n° 1 des rapports personnalisés.suspended: compte gelé (connexion refusée) mais données intactes.mnethostid: vestige de MNet (fédération de Moodle) ; vaut l’id de l’hôte local pour 99,99 % des lignes, mais participe à l’index unique(mnethostid, username)— un username seul n’est donc théoriquement pas unique.lastaccess,timecreated,timezone,lang, et une kyrielle de préférences.
Deux lignes spéciales existent toujours : l’utilisateur invité (username = 'guest') et l’admin initial. Ne présumez jamais que id = 2 est l’admin (c’est fréquent mais non garanti).
9.3 course et course_categories : la structure pédagogique
course_categories forme un arbre matérialisé : chaque catégorie a un parent (0 pour la racine), mais surtout un path (/1/3/7 : ids des ancêtres, slash-séparés, self inclus) et un depth. Ce pattern path/depth — que vous retrouverez à l’identique dans context — permet de trouver tous les descendants d’une catégorie en une requête : WHERE path LIKE '/1/3/%' (avec sql_like(), évidemment).
course : category (FK), fullname (nom complet affiché), shortname (nom court unique, utilisé dans les fils d’Ariane et les références), idnumber (identifiant externe pour vos synchronisations SI — vide par défaut), format (le plugin de format de cours : topics, weeks, ou un format tiers), visible (0 = caché aux étudiants), startdate/enddate, summary/summaryformat.
⚠️ Piège : le cours d’id 1 n’est pas un cours : c’est la page d’accueil du site (« frontpage »), stockée dans
coursepour des raisons historiques. Toute requête « liste des cours » doit exclureid = 1(ou filtrer surcategory != 0, la frontpage étant la seule ligne aveccategory = 0). Un rapport qui compte les cours sans cette exclusion est faux, et un code qui itère « tous les cours » pour les modifier peut faire des dégâts sur la frontpage.
9.4 course_modules : LA table pivot des activités
C’est la table la plus subtile du schéma, celle qui déroute tous les nouveaux venus. Le problème qu’elle résout : un cours contient des activités de types hétérogènes (3 quiz, 5 forums, 12 pages…), chacune avec sa propre table de données. Comment les référencer uniformément ? Par une table de jonction à trois liens :
course_modules.course→ le cours (course.id) ;course_modules.module→ le type d’activité (modules.id, oùmodules.namevaut'quiz','forum'…) ;course_modules.instance→ l’id dans la table du module concerné (quiz.idsi le type est quiz,forum.idsi c’est un forum…). C’est une FK polymorphe : la table cible dépend de la valeur demodule.
S’y ajoutent : section (FK vers course_sections.id — et, redondance historique à connaître, course_sections.sequence stocke la liste CSV ordonnée des ids de course_modules de la section : les deux doivent rester synchrones, d’où l’interdiction absolue de modifier ces colonnes à la main), visible, idnumber (référence externe, utilisée par le carnet de notes), groupmode, completion (suivi d’achèvement), deletioninprogress (suppression asynchrone par le cron), added.
Concrètement, quand une URL contient ?id=318 sur une page de quiz, ce 318 est un id de course_modules (le « cmid »), pas un id de quiz. La traduction dans les deux sens est un rituel quotidien :
// J'ai un cmid (depuis l'URL) : je veux le cours, le cm et l'instance de quiz.
$cmid = required_param('id', PARAM_INT);
// Helper historique : vérifie que le cm existe ET est bien un quiz,
// retourne un objet enrichi (->instance est l'id dans la table quiz).
$cm = get_coursemodule_from_id('quiz', $cmid, 0, false, MUST_EXIST);
$course = get_course($cm->course); // Cache-friendly.
$quiz = $DB->get_record('quiz', ['id' => $cm->instance], '*', MUST_EXIST);
// Sens inverse : j'ai un id d'instance (quiz.id) et je veux le cmid.
$cm = get_coursemodule_from_instance('quiz', $quiz->id, $course->id, false, MUST_EXIST);Mais la méthode moderne et performante pour travailler avec les activités d’un cours est get_fast_modinfo(), qui charge en un coup (et met en cache agressivement via MUC) toute la structure du cours et retourne des objets cm_info riches :
$modinfo = get_fast_modinfo($course); // 1 accès cache, pas N requêtes.
// Toutes les instances de cm_info du cours, indexées par cmid :
foreach ($modinfo->cms as $cmid => $cm) {
// $cm est un cm_info : ->id (cmid), ->instance, ->modname ('quiz'),
// ->name (nom affiché), ->url, ->visible, ->uservisible (visibilité
// calculée POUR L'UTILISATEUR COURANT : restrictions d'accès incluses)...
if ($cm->modname === 'quiz' && $cm->uservisible) {
echo html_writer::link($cm->url, $cm->get_formatted_name());
}
}
// Accès direct par cmid, ou par type :
$cm = $modinfo->get_cm($cmid);
$quizzes = $modinfo->get_instances_of('quiz'); // cm_info[], indexés par instance id.💡 Pour un dev React :
course_modulesest une table de relation polymorphe, le pattern que Prisma ne supporte toujours pas nativement (vous l’auriez émulé avec un champtype+ plusieurs relations optionnelles, ou des tables de jonction par type). Moodle l’assume via le duomodule(le type) +instance(l’id typé). Etget_fast_modinfo()joue le rôle d’un dataloader + cache : ne faites jamais deget_records('course_modules', ['course' => $id])suivi de N requêtes vers les tables d’instances — c’est le problème N+1 classique, déjà résolu pour vous.
9.5 context : la colonne vertébrale des permissions
Chaque « endroit » de Moodle où des permissions peuvent s’appliquer possède une ligne dans context. La colonne contextlevel indique le type d’endroit, instanceid pointe vers la table correspondante :
| contextlevel | Constante | instanceid pointe vers | Exemple |
|---|---|---|---|
| 10 | CONTEXT_SYSTEM | — (instanceid 0) | Le site entier |
| 30 | CONTEXT_USER | user.id | Le profil d’un utilisateur |
| 40 | CONTEXT_COURSECAT | course_categories.id | Une catégorie de cours |
| 50 | CONTEXT_COURSE | course.id | Un cours |
| 70 | CONTEXT_MODULE | course_modules.id | Une activité (un quiz précis) |
| 80 | CONTEXT_BLOCK | block_instances.id | Un bloc |
(Le niveau 60, jamais attribué, était réservé aux groupes — d’où le trou dans la numérotation.)
Comme course_categories, context matérialise sa hiérarchie avec path et depth : le contexte d’un quiz du cours 25 a un path du type /1/27/318/412 (système → catégorie → cours → module). Cette hiérarchie est le mécanisme d’héritage des permissions : un rôle donné dans un contexte s’applique à tous les contextes descendants. Vérifier « cet utilisateur peut-il voir ce quiz ? » revient à remonter le path et agréger les permissions — c’est ce que fait has_capability(), sur laquelle nous reviendrons longuement au chapitre consacré aux permissions.
En code, on ne requête jamais context directement : on passe par les classes dédiées, qui gèrent création paresseuse et caches :
$syscontext = context_system::instance();
$coursecontext = context_course::instance($courseid); // instanceid = courseid.
$modcontext = context_module::instance($cmid); // instanceid = CMID, pas l'instance !
// L'objet expose id, path, depth, et les parcours :
$parents = $modcontext->get_parent_contexts();⚠️ Piège : pour un contexte de module,
instanceidest l’id de course_modules (le cmid), pas l’id dans la table du module. Confondrequiz.idetcm->iden créant ou cherchant un contexte est une erreur silencieuse dévastatrice : vous obtiendrez un contexte… mais pas le bon, et les fichiers/permissions iront se ranger sous la mauvaise activité. Quand un id d’activité transite dans votre code, nommez vos variables sans ambiguïté :$cmidvs$quizid.
9.6 Rôles et assignations : « inscrit » ≠ « a un rôle »
Quatre tables se partagent le travail :
capabilities: le catalogue de toutes les permissions atomiques déclarées par le core et les plugins (mod/quiz:attempt,moodle/course:update…), avec leur niveau de contexte naturel et leur masque de risques. Alimentée par les fichiersdb/access.phpdes plugins.role: les rôles (student,teacher,editingteacher,manager… plus les vôtres), avec leurarchetypequi sert de modèle de permissions.role_capabilities: pour un rôle, dans un contexte donné, la valeur d’une capability :permissionvaut 1 (allow), -1 (prevent) ou -1000 (prohibit, non surchargeable). Les définitions « globales » d’un rôle vivent dans le contexte système ; les overrides locaux (« dans CE cours, les étudiants peuvent… ») créent des lignes sur d’autres contextid.role_assignments: le cœur — qui (userid) a quel rôle (roleid) où (contextid). Les colonnescomponent/itemidtracent l’origine de l’assignation quand elle est automatique (par exempleenrol_manual).
Et maintenant, la distinction conceptuelle que même des développeurs Moodle expérimentés maîtrisent mal : être inscrit à un cours et avoir un rôle dans un cours sont deux choses indépendantes, stockées dans des tables différentes.
- L’inscription (enrolment, tables
enrol+user_enrolments, section 9.7) donne le statut de « participant » : apparition dans la liste des participants, accès au cours, notes dans le carnet. - L’assignation de rôle (
role_assignments) donne des permissions dans un contexte.
Dans le flux normal, les deux vont ensemble : le plugin d’inscription crée user_enrolments et une ligne role_assignments (rôle étudiant dans le contexte du cours). Mais chacune existe sans l’autre : un manager assigné au niveau d’une catégorie a des permissions sur tous les cours de la catégorie sans être inscrit à aucun (il ne figure pas dans les participants) ; à l’inverse, on peut inscrire quelqu’un « sans rôle » (participant sans permissions particulières). En code : is_enrolled($context, $user) teste l’inscription, has_capability(...) teste les permissions — ne déduisez jamais l’un de l’autre.
9.7 enrol et user_enrolments : les inscriptions
Le modèle est en deux étages, ce qui surprend :
enrol: les instances de méthodes d’inscription d’un cours. Chaque ligne = « ce cours propose telle méthode » :enrol(le nom du plugin :manual,self,cohort,guest…),courseid,status(activée/désactivée),roleid(le rôle donné par défaut, généralement student), et des colonnes génériquescustomint1..8/customchar1..3que chaque plugin interprète à sa façon. Un cours typique a 2-3 lignes ici (manual + self + guest).user_enrolments: les inscriptions effectives :enrolid(FK vers l’instance de méthode — c’est ainsi qu’on sait comment la personne est inscrite),userid,status(0 = ENROL_USER_ACTIVE, 1 = suspendu),timestart/timeend(fenêtre de validité, 0 = illimité).
Donc pour trouver les inscrits d’un cours en SQL : user_enrolments JOIN enrol ON enrol.id = user_enrolments.enrolid WHERE enrol.courseid = X. Mais en PHP, préférez les API : get_enrolled_users($context), ou pour vos requêtes custom get_enrolled_sql($context) qui vous génère le fragment SQL (avec paramètres) sélectionnant les ids d’inscrits actifs — filtres de statut et de dates inclus, que tout le monde oublie en SQL manuel.
9.8 grade_items et grade_grades : le carnet de notes (aperçu)
Le carnet de notes mérite son propre chapitre ; retenez ici le duo central. grade_items : une ligne par « colonne » du carnet d’un cours — itemtype vaut 'mod' (note produite par une activité, avec itemmodule = 'quiz' et iteminstance = quiz.id), 'manual' (colonne créée à la main), ou 'course' (la ligne spéciale du total de cours, une par cours), plus grademax/grademin, la stratégie d’agrégation, etc. grade_grades : une ligne par (item, utilisateur) — rawgrade (note brute envoyée par l’activité) et surtout finalgrade (note finale après agrégations et overrides : c’est elle qu’un rapport doit lire). Les activités poussent leurs notes via l’API (grade_update()), jamais par écriture directe dans ces tables.
9.9 files : où sont les fichiers (aperçu)
Aucun fichier utilisateur ne vit sur le disque à un chemin prévisible : tout passe par l’API Files (chapitre 05). La table files est l’index : contenthash (SHA-1 du contenu — le fichier physique est stocké dans moodledata sous ce hash, dédupliqué : deux profs qui déposent le même PDF ne le stockent qu’une fois), et le quadruplet d’adressage logique contextid + component + filearea + itemid (+ filepath/filename). Exemple : l’énoncé d’un devoir = contexte du module, component mod_assign, filearea intro, itemid 0. Vous retrouvez contextid : encore le système de contextes qui sert de colonne vertébrale — supprimez une activité, et tous ses fichiers sont retrouvables (et purgeables) par son contexte.
9.10 Groupes et cohortes (brefs)
groups (courseid, name, idnumber) et groups_members (groupid, userid) : les groupes vivent dans un cours et servent à cloisonner les activités (mode groupes de course_modules.groupmode). Les groupings (groupings, groupings_groups) sont des ensembles de groupes. cohort (contextid — système ou catégorie —, name, idnumber) et cohort_members : les cohortes sont des listes d’utilisateurs transverses au site, typiquement synchronisées depuis le SI, et servent surtout de source d’inscription en masse (via le plugin enrol_cohort). Groupe = intra-cours et pédagogique ; cohorte = niveau site et administratif.
9.11 config et config_plugins : la configuration
Deux tables clé/valeur toutes simples : config (name, value) pour la configuration du core — chaque ligne devient une propriété de l’objet global $CFG ($CFG->enablecompletion…) ; config_plugins (plugin, name, value) pour celle des plugins — y compris, on l’a vu, la ligne version qui pilote les upgrades. En code, on ne les requête jamais directement : get_config('local_bookshelf', 'apikey') et set_config('apikey', $value, 'local_bookshelf') gèrent le cache. Toutes les valeurs sont des chaînes ; les valeurs définies en dur dans config.php ($CFG->xxx) priment sur la table et deviennent non modifiables par l’interface.
9.12 logstore_standard_log : la boîte noire
Chaque événement significatif (chapitre 06) est journalisé ici par le magasin de logs standard : eventname (la classe de l’événement : \mod_quiz\event\attempt_submitted), component, action, target, le triptyque objecttable/objectid (la ligne concernée), crud (c/r/u/d), edulevel, puis la localisation — contextid, contextlevel, contextinstanceid, courseid — et les acteurs — userid (qui a agi), relateduserid (sur qui) —, timecreated, et other (données additionnelles sérialisées en JSON). C’est de très loin la plus grosse table d’un site actif (des dizaines de millions de lignes) : toute requête dessus doit exploiter les index (timecreated, courseid, userid…) et, dans vos traitements, c’est recordset obligatoire.
9.13 Quatre requêtes réalistes, commentées ligne à ligne
Vérifions que le schéma mental est en place en écrivant les requêtes que tout développeur Moodle finit par écrire un jour.
Requête 1 — Les étudiants inscrits à un cours, avec leur note totale :
$sql = "SELECT DISTINCT u.id, u.firstname, u.lastname, u.email,
gg.finalgrade
-- Axe personnes : on part des inscriptions effectives...
FROM {user_enrolments} ue
-- ...qu'on rattache à leur méthode pour connaître le cours :
JOIN {enrol} e ON e.id = ue.enrolid AND e.courseid = :courseid1
-- ...et à l'utilisateur (jamais sans le filtre deleted !) :
JOIN {user} u ON u.id = ue.userid AND u.deleted = 0
-- Axe permissions : on exige le rôle étudiant DANS LE CONTEXTE du cours.
-- Rappel : inscrit ≠ a un rôle, d'où cette double vérification.
JOIN {context} ctx ON ctx.contextlevel = 50 AND ctx.instanceid = :courseid2
JOIN {role_assignments} ra ON ra.contextid = ctx.id AND ra.userid = u.id
JOIN {role} r ON r.id = ra.roleid AND r.shortname = 'student'
-- Axe notes : LEFT JOIN car un étudiant peut ne pas encore avoir de note.
-- grade_items itemtype='course' = la ligne "total du cours" (une par cours).
LEFT JOIN {grade_items} gi ON gi.courseid = e.courseid AND gi.itemtype = 'course'
LEFT JOIN {grade_grades} gg ON gg.itemid = gi.id AND gg.userid = u.id
-- Inscription active (0 = ENROL_USER_ACTIVE) et dans sa fenêtre de validité :
WHERE ue.status = 0
AND (ue.timeend = 0 OR ue.timeend > :now)
ORDER BY u.lastname, u.firstname";
// courseid deux fois → deux placeholders distincts (règle des paramètres nommés).
$students = $DB->get_records_sql($sql,
['courseid1' => $courseid, 'courseid2' => $courseid, 'now' => time()]);(Le DISTINCT protège contre les doubles inscriptions — un utilisateur inscrit à la fois manuellement et par cohorte a deux lignes user_enrolments. Et première colonne = u.id, unique grâce au DISTINCT : la règle de la section 4.2 est respectée.)
Requête 2 — Tous les quiz d’un cours avec leur cmid et leur section (le triple lien en action) :
$sql = "SELECT cm.id AS cmid, q.id AS quizid, q.name, q.timeclose,
cs.section AS sectionnumber, cm.visible
FROM {course_modules} cm
-- Lien 1 : quel TYPE d'activité ? On ne garde que les quiz.
JOIN {modules} m ON m.id = cm.module AND m.name = 'quiz'
-- Lien 2 : l'instance. cm.instance = quiz.id PARCE QUE m.name='quiz' —
-- c'est la jointure polymorphe : la table cible dépend du type.
JOIN {quiz} q ON q.id = cm.instance
-- Lien 3 : la section qui héberge l'activité.
JOIN {course_sections} cs ON cs.id = cm.section
WHERE cm.course = :courseid
-- Les activités en cours de suppression asynchrone sont encore en base :
AND cm.deletioninprogress = 0
ORDER BY cs.section, q.name";
$quizzes = $DB->get_records_sql($sql, ['courseid' => $courseid]);
// (En production, get_fast_modinfo() ferait cela sans SQL — cette requête
// se justifie dans un rapport site-wide ou une tâche CLI multi-cours.)Requête 3 — Activité récente : qui a fait quoi dans un cours cette semaine (logs) :
$sql = "SELECT l.id, l.timecreated, l.eventname, l.action, l.target,
u.firstname, u.lastname
FROM {logstore_standard_log} l
JOIN {user} u ON u.id = l.userid AND u.deleted = 0
WHERE l.courseid = :courseid
-- TOUJOURS borner par timecreated : c'est la clé du plan d'index
-- sur cette table géante.
AND l.timecreated > :since
-- On ne garde que les vraies actions (c/u/d), pas les lectures :
AND l.crud <> 'r'
ORDER BY l.timecreated DESC";
// Table potentiellement énorme + résultat potentiellement large :
// recordset, pas get_records (section 4.6).
$rs = $DB->get_recordset_sql($sql, ['courseid' => $courseid, 'since' => time() - WEEKSECS]);
foreach ($rs as $entry) {
// ... agrégation, export CSV, etc.
}
$rs->close();Requête 4 — Poids des fichiers déposés par activité d’un cours (files + context + cm) :
$sql = "SELECT cm.id AS cmid, m.name AS modtype,
COUNT(f.id) AS filecount, SUM(f.filesize) AS totalsize
FROM {files} f
-- files s'adresse par contexte : on remonte au contexte de module...
JOIN {context} ctx ON ctx.id = f.contextid AND ctx.contextlevel = 70
-- ...dont l'instanceid est un id de course_modules (le fameux cmid) :
JOIN {course_modules} cm ON cm.id = ctx.instanceid
JOIN {modules} m ON m.id = cm.module
WHERE cm.course = :courseid
-- Chaque dossier contient une entrée "." de taille 0 (filename '.') :
-- on exclut les répertoires pour ne compter que les vrais fichiers.
AND f.filename <> '.'
GROUP BY cm.id, m.name
ORDER BY totalsize DESC";
$usage = $DB->get_records_sql($sql, ['courseid' => $courseid]);
// Première colonne cm.id : unique car on groupe dessus. ✔📚 Aller plus loin : pour explorer le schéma complet table par table, deux ressources : le rapport « XMLDB documentation » de l’éditeur XMLDB lui-même (il génère une doc HTML de toutes les tables installées, avec commentaires), et le site communautaire de visualisation du schéma référencé depuis la page Database de moodledev.io. Prenez aussi l’habitude d’ouvrir
lib/db/install.xml: les commentaires des colonnes du core sont la documentation la plus fiable qui soit.
10. Résumé — points clés
Philosophie. Moodle abstrait totalement le SGBD (MySQL/MariaDB, PostgreSQL, SQL Server — Oracle retiré depuis 5.0) : jamais de SQL spécifique à un moteur, jamais de préfixe en dur — les tables s’écrivent {tablename} dans le SQL brut, sans accolades dans les méthodes haut niveau.
XMLDB. Le schéma d’un plugin vit dans db/install.xml (le schema.prisma de Moodle), édité via l’éditeur XMLDB intégré, jamais à la main. Six types de champs seulement ; les dates sont des timestamps Unix en int(10) ; id auto-incrémenté obligatoire ; trio usermodified/timecreated/timemodified conventionnel. Les clés étrangères XMLDB sont purement déclaratives : aucune contrainte SGBD, aucun cascade — l’intégrité référentielle est de votre responsabilité, en PHP.
Upgrades. version.php (version YYYYMMDDXX) comparée à config_plugins déclenche db/upgrade.php : une suite de blocs if ($oldversion < N) utilisant le database_manager ($dbman), chacun clôturé par upgrade_plugin_savepoint() pour survivre aux interruptions. Règle d’or : toute modification d’install.xml exige son étape d’upgrade jumelle, et on teste toujours les deux chemins (install fraîche + upgrade).
DML. $DB est l’unique porte d’entrée : familles get_record*/get_records*/get_field*/count_records*/record_exists* en trois saveurs (conditions, _select, _sql) ; strictness MUST_EXIST pour les absences anormales ; les tableaux retournés sont indexés par la première colonne du SELECT, qui doit être unique ; recordsets (+ close() obligatoire) pour tout gros volume. Écriture : insert_record (retourne l’id), insert_records (bulk, sans ids), update_record (id requis), set_field, delete_records, execute() en dernier recours.
Sécurité et portabilité. Placeholders partout (? ou :name, jamais mélangés, un nommé = un usage) ; jamais de concaténation de valeurs ; identifiants dynamiques uniquement par whitelist. Les divergences SQL passent par les helpers : get_in_or_equal() (attention au tableau vide), sql_like()/sql_like_escape(), sql_concat(), sql_fullname(), sql_compare_text() (attention à la troncature), casts et tests de vide.
Transactions. start_delegated_transaction() → allow_commit() ou rollback($e) ; « déléguées » = imbricables par comptage, seul le niveau externe commite réellement ; jamais de DDL dedans ; les effets de bord non-SQL ne sont pas annulés par un rollback.
Le schéma mental. Trois axes : structure (course_categories → course → course_sections → course_modules, la table pivot au triple lien course/module/instance, à manipuler via get_fast_modinfo()), personnes (user — filtrer deleted = 0 ! — → enrol/user_enrolments), et l’axe transversal context (levels 10/30/40/50/70/80, hiérarchie par path) qui porte rôles (role_assignments, role_capabilities), fichiers (files) et logs. Et la distinction fondatrice : être inscrit ≠ avoir un rôle — deux mécanismes, deux jeux de tables, deux APIs (is_enrolled() vs has_capability()).
Vous disposez maintenant du socle : définir des tables, les faire évoluer, les interroger proprement, et naviguer dans le schéma du core. Le prochain chapitre s’appuie directement dessus : la Form API, ou comment Moodle transforme des définitions PHP en formulaires validés dont les données finissent… dans le DML que vous venez d’apprendre.
Chapitre précédent : 01 — Vue d’ensemble · Chapitre suivant : 03 — Form API