Skip to Content
MoodlePartie 6 — Développer des pluginsTutoriel complet : un plugin local (`local_todolist`)

Chapitre 6.3 — Tutoriel complet : un plugin local (local_todolist)

Partie 6 : Développer des plugins — Chapitre 3/7 Public : développeur web (Next.js / React) ayant lu les Parties 4 et 5 et les chapitres 6.1–6.2. Vous avez le modèle mental (frankenstyle, DML, Form API, Output API, hooks) ; on construit. Version de référence : Moodle 5.2 (PHP 8.3+, racine web public/).

⏱️ TL;DR

  • On construit local_todolist de A à Z : une todo-list personnelle par utilisateur, avec table, permissions, formulaire, CRUD, rendu Mustache, entrée de navigation, réglages admin et une chaîne d’upgrade réelle.
  • Un plugin local (local_*) est le couteau suisse : idéal pour une fonctionnalité transversale qui ne rentre pas dans un autre type (activité, bloc…). Il s’installe dans local/todolist/, son component est local_todolist.
  • Fichiers du contrat : version.php (identité + version), db/install.xml (schéma XMLDB), db/access.php (capabilities), db/upgrade.php (migrations), lang/en/local_todolist.php (chaînes), settings.php (réglages admin), lib.php (callbacks dont la navigation), index.php/edit.php (pages), classes/ (form, manager, output), templates/ (Mustache).
  • Toute page suit le pattern sécurisé : require_login() → contexte → require_capability()$PAGE->set_*$OUTPUT->header()/footer(). Toute action mutante exige require_sesskey().
  • Le CRUD passe par l’API DML ($DB->insert_record, get_records, update_record, delete_records) avec des placeholders (jamais de SQL concaténé).
  • Les données sortent via un renderable + renderer + template Mustache (Output API), jamais en echo de HTML brut.
  • L’upgrade (v2) ajoute une colonne duedate : on modifie install.xml, on écrit le bloc correspondant dans upgrade.php avec un savepoint, et on bumpe version.php. Moodle rejoue la migration automatiquement.

🎯 Objectifs

À la fin de ce chapitre, vous saurez :

  • Créer un plugin local fonctionnel, fichier par fichier, et l’installer.
  • Définir un schéma avec XMLDB et des capabilities avec db/access.php.
  • Écrire une page sécurisée (le pattern à réutiliser partout) et un formulaire moodleform.
  • Faire un CRUD propre via l’API DML et afficher via l’Output API (renderable + Mustache).
  • Ajouter une entrée de navigation et une page de réglages admin.
  • Écrire et jouer une mise à niveau de schéma (ajout de colonne) avec savepoint.

1. Le cahier des charges et le choix du type

Nous voulons offrir à chaque utilisateur connecté une petite todo-list personnelle, accessible partout dans Moodle, indépendante des cours. Un utilisateur peut : ajouter une tâche, la marquer faite/à faire, la modifier, la supprimer. En version 2, on ajoutera une date d’échéance.

Pourquoi un plugin local_ ? Rappel de l’arbre de décision du chapitre 6.1 : ce n’est pas une activité de cours (donc pas mod_), pas une boîte latérale (donc pas block_), pas une méthode d’authentification, etc. C’est une fonctionnalité transversale, propre au site, rattachée à l’utilisateur et non à un cours. Le type local est exactement fait pour ça : c’est le fourre-tout légitime pour « du code Moodle qui ne rentre nulle part ailleurs ».

💡 Pour un dev React : un plugin local_ est l’équivalent d’un module de feature autonome dans votre monorepo — il a son schéma (sa « table Prisma »), ses routes (ses pages index.php/edit.php), ses composants (templates Mustache) et ses permissions. La différence : la structure de fichiers est imposée par convention (le « contrat » du type local), et c’est cette structure qui déclenche installation, migrations et permissions.

Arborescence finale que nous allons construire :

public/local/todolist/ ├── version.php ├── settings.php ├── lib.php ├── index.php ├── edit.php ├── db/ │ ├── access.php │ ├── install.xml │ └── upgrade.php ├── classes/ │ ├── manager.php │ ├── form/ │ │ └── todo_form.php │ └── output/ │ ├── todo_list.php │ └── renderer.php ├── templates/ │ └── todo_list.mustache └── lang/ ├── en/ │ └── local_todolist.php └── fr/ └── local_todolist.php

⚠️ Piège (5.2) : depuis la 5.1, la racine web est public/. Le code des plugins livrés dans le core vit sous public/local/…, public/mod/…, etc. Pour votre plugin, placez-le donc sous public/local/todolist/. Dans le reste du chapitre, les chemins d’URL (/local/todolist/index.php) restent inchangés — c’est le document root qui pointe sur public/. Si votre install de dev est plus ancienne, adaptez (local/todolist/).


2. version.php : la carte d’identité

Tout plugin commence par version.php. C’est l’équivalent du package.json : il déclare qui est le plugin et quelle version de son code tourne.

<?php // public/local/todolist/version.php defined('MOODLE_INTERNAL') || die(); $plugin->component = 'local_todolist'; // frankenstyle : type_nom. OBLIGATOIRE, doit matcher le dossier. $plugin->version = 2026070800; // AAAAMMJJXX : la version de CE code. On la bumpera à l'upgrade. $plugin->requires = 2026042000; // version minimale de Moodle requise (ici 5.2). $plugin->maturity = MATURITY_STABLE; // ALPHA / BETA / RC / STABLE. $plugin->release = 'v1.0'; // libellé lisible par un humain.

Quatre points essentiels :

  • component est la vérité absolue du plugin. local_todolist = type local + nom todolist. Le nom du dossier (todolist) doit correspondre. Toute incohérence casse l’installation.
  • version (YYYYMMDDXX) est le nombre monotone du chapitre 1.5 : quand le code sur disque annonce une version supérieure à celle enregistrée en base, Moodle déclenche la mise à niveau. C’est le déclencheur de db/upgrade.php.
  • requires empêche l’installation sur un Moodle trop ancien (ici, la valeur de la 5.2).
  • defined('MOODLE_INTERNAL') || die(); en tête de (presque) tous les fichiers PHP : garde-fou empêchant l’accès direct au fichier hors du contexte Moodle. Réflexe de sécurité (Partie 11).

⚠️ Piège : ne jamais baisser ni « réutiliser » un numéro de version. Le mécanisme d’upgrade compare des entiers : une version qui recule met le plugin dans un état incohérent. À chaque changement de schéma ou de comportement notable, on incrémente.


3. Le schéma : db/install.xml

Notre todo-list a besoin d’une table. Dans Moodle, on ne écrit jamais de CREATE TABLE SQL : on décrit le schéma en XMLDB (portable entre PostgreSQL, MySQL/MariaDB), et Moodle génère le SQL adapté à la base cible.

En pratique, on ne rédige pas ce fichier à la main : on utilise l’éditeur XMLDB intégré (Administration du site → Développement → XMLDB editor), qui produit ce XML. Le voici pour notre version 1 :

<?xml version="1.0" encoding="UTF-8" ?> <!-- public/local/todolist/db/install.xml --> <XMLDB PATH="local/todolist/db" VERSION="2026070800" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="../../../lib/xmldb/xmldb.xsd"> <TABLES> <TABLE NAME="local_todolist" COMMENT="Tâches personnelles des utilisateurs"> <FIELDS> <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/> <FIELD NAME="userid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="Propriétaire de la tâche (fk vers user)"/> <FIELD NAME="name" TYPE="char" LENGTH="255" NOTNULL="true" SEQUENCE="false" COMMENT="Intitulé de la tâche"/> <FIELD NAME="completed" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="0 = à faire, 1 = terminée"/> <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> <KEY NAME="primary" TYPE="primary" FIELDS="id"/> <KEY NAME="userid" TYPE="foreign" FIELDS="userid" REFTABLE="user" REFFIELDS="id"/> </KEYS> <INDEXES> <INDEX NAME="userid-completed" UNIQUE="false" FIELDS="userid, completed"/> </INDEXES> </TABLE> </TABLES> </XMLDB>

À décoder :

  • Le nom de table est préfixé par le component : local_todolist. En base, Moodle ajoute le préfixe d’installation (souvent mdl_), donnant mdl_local_todolist. Ne mettez jamais le préfixe mdl_ vous-même — Moodle s’en charge.
  • Le champ id avec SEQUENCE="true" est l’auto-incrément obligatoire ; c’est la clé primaire de toute table Moodle.
  • userid est déclaré comme clé étrangère vers user(id) : documentaire et utile à l’intégrité, même si Moodle ne pose pas toujours de contrainte FK physique.
  • Un index userid, completed : nos requêtes filtreront toujours par userid (« mes tâches »), souvent par completed. Indexer ce couple, c’est de la performance élémentaire (Partie 11).
  • L’attribut VERSION de la balise <XMLDB> reflète la version du schéma ; l’éditeur XMLDB le tient à jour.

💡 Pour un dev React : install.xml est votre schéma Prisma décrit en XML portable, et db/upgrade.php (plus loin) est votre dossier de migrations. Moodle joue install.xml une seule fois, à la première installation ; ensuite, toute évolution du schéma passe par upgrade.php. Ne modifiez jamais install.xml sans écrire la migration correspondante, sinon les nouvelles installs et les installs existantes divergent (voir §11).


4. Les permissions : db/access.php

Qui a le droit de gérer ses tâches ? Tout utilisateur connecté. On déclare une capability et on l’accorde par défaut au rôle « utilisateur authentifié ».

<?php // public/local/todolist/db/access.php defined('MOODLE_INTERNAL') || die(); $capabilities = [ 'local/todolist:manageentries' => [ 'captype' => 'write', 'contextlevel' => CONTEXT_SYSTEM, 'archetypes' => [ // 'user' = le rôle « utilisateur authentifié » : tout le monde de connecté l'obtient. 'user' => CAP_ALLOW, ], ], ];

Ce qu’il faut comprendre :

  • Le nom de la capability est frankenstyle avec un : : local/todolist:manageentries. (Le type utilise un /, pas un _, dans les noms de capabilities.)
  • captype (read/write) documente la nature de l’action et alimente certains rapports de sécurité.
  • contextlevel = CONTEXT_SYSTEM : la permission se raisonne au niveau du site (les tâches ne sont pas rattachées à un cours).
  • archetypesuserCAP_ALLOW : l’astuce clé. L’archétype user correspond au rôle utilisateur authentifié. Résultat : dès qu’on est connecté, on a la permission. (Pas d’archétype « guest » ici : un visiteur anonyme n’a pas de todo-list.)

⚠️ Piège : db/access.php n’est (re)lu par Moodle qu’à l’installation et à chaque upgrade (bump de version.php). Si vous modifiez une capability sans bumper la version, Moodle ne la voit pas. Réflexe : toute modif de access.php ⇒ nouveau version.

💡 Pour un dev React : une capability, c’est un droit nommé et granulaire, pas un « rôle ». Le rôle (student, teacher…) est une collection de capabilities, résolue par contexte. Ici, on ne vérifiera jamais « es-tu admin ? » mais « as-tu local/todolist:manageentries sur ce contexte ? » via has_capability() / require_capability(). C’est le cœur du modèle de la Partie 2.


5. Les chaînes de langue : lang/en/local_todolist.php

Dans Moodle, aucun texte visible n’est écrit en dur. Tout passe par la String API et des fichiers de langue. La langue anglaise est obligatoire (langue pivot) ; les autres sont optionnelles.

<?php // public/local/todolist/lang/en/local_todolist.php defined('MOODLE_INTERNAL') || die(); $string['pluginname'] = 'My to-do list'; $string['todolist:manageentries'] = 'Manage own to-do entries'; // description de la capability // Navigation / pages. $string['mytodos'] = 'My to-do list'; $string['addtask'] = 'Add a task'; $string['edittask'] = 'Edit task'; $string['notasks'] = 'You have no tasks yet. Add your first one!'; // Champs de formulaire. $string['taskname'] = 'Task'; $string['taskname_help'] = 'A short description of what you need to do.'; $string['duedate'] = 'Due date'; // utilisé à partir de la v2 $string['duedate_help'] = 'Optional deadline for this task.'; // Actions. $string['markdone'] = 'Mark as done'; $string['markundone'] = 'Mark as to do'; $string['deletetask'] = 'Delete'; $string['confirmdelete'] = 'Are you sure you want to delete “{$a}”?'; // Réglages admin. $string['maxtasks'] = 'Maximum tasks per user'; $string['maxtasks_desc'] = 'Hard limit on the number of tasks a user can create (0 = unlimited).'; // Privacy (Partie 11). $string['privacy:metadata:local_todolist'] = 'Personal to-do entries created by the user.'; $string['privacy:metadata:local_todolist:userid'] = 'The user who owns the task.'; $string['privacy:metadata:local_todolist:name'] = 'The task text.';

Et la version française (optionnelle mais bienvenue pour ce cours) :

<?php // public/local/todolist/lang/fr/local_todolist.php defined('MOODLE_INTERNAL') || die(); $string['pluginname'] = 'Ma liste de tâches'; $string['todolist:manageentries'] = 'Gérer ses propres tâches'; $string['mytodos'] = 'Ma liste de tâches'; $string['addtask'] = 'Ajouter une tâche'; $string['edittask'] = 'Modifier la tâche'; $string['notasks'] = "Vous n'avez aucune tâche. Ajoutez la première !"; $string['taskname'] = 'Tâche'; $string['taskname_help'] = "Une courte description de ce que vous devez faire."; $string['duedate'] = "Date d'échéance"; $string['duedate_help'] = "Échéance facultative pour cette tâche."; $string['markdone'] = 'Marquer comme faite'; $string['markundone'] = 'Marquer comme à faire'; $string['deletetask'] = 'Supprimer'; $string['confirmdelete'] = 'Supprimer « {$a} » ?'; $string['maxtasks'] = 'Nombre maximum de tâches par utilisateur'; $string['maxtasks_desc'] = "Limite du nombre de tâches par utilisateur (0 = illimité).";

Notez le placeholder {$a} : la String API y injecte une valeur (ici le nom de la tâche à supprimer). On l’utilise en PHP via get_string('confirmdelete', 'local_todolist', $task->name).

⚠️ Piège : la clé pluginname est obligatoire — c’est le nom affiché du plugin dans la liste d’administration. Son absence provoque un vilain [[pluginname]] partout. Et souvenez-vous : après tout ajout de chaîne, purgez les caches (php admin/cli/purge_caches.php) — les langues sont mises en cache.


6. Les réglages admin : settings.php

Ajoutons un réglage global : le nombre maximum de tâches par utilisateur. Le fichier settings.php est chargé automatiquement pour construire l’arbre de l’administration.

<?php // public/local/todolist/settings.php defined('MOODLE_INTERNAL') || die(); if ($hassiteconfig) { // N'ajoute les réglages que si l'utilisateur peut configurer le site. $settings = new admin_settingpage( 'local_todolist', get_string('pluginname', 'local_todolist') ); $settings->add(new admin_setting_configtext( 'local_todolist/maxtasks', // nom du réglage : component/clé get_string('maxtasks', 'local_todolist'), // libellé get_string('maxtasks_desc', 'local_todolist'),// description 20, // valeur par défaut PARAM_INT // validation du type )); $ADMIN->add('localplugins', $settings); // range la page sous « Plugins locaux ». }

Points clés :

  • La garde if ($hassiteconfig) évite d’exposer la page à qui n’a pas le droit de configurer le site.
  • Le nom du réglage local_todolist/maxtasks définit la valeur est stockée. On la relira avec get_config('local_todolist', 'maxtasks').
  • PARAM_INT contraint le type : la Form API des réglages valide l’entrée. (Catalogue complet des PARAM_* en Partie 11.)
  • $ADMIN->add('localplugins', …) place la page dans la catégorie Plugins → Plugins locaux de l’administration.

⚠️ Piège : comme access.php, settings.php est pris en compte au (re)chargement de la structure d’admin, qui suit l’installation/upgrade et la purge des caches. Un réglage « qui n’apparaît pas » = presque toujours un cache à purger.


7. La couche métier : classes/manager.php

Plutôt que d’éparpiller les requêtes DML dans les pages, on centralise le CRUD dans une classe autoloadée. Tout ce qui est dans classes/ est chargé automatiquement (PSR-like) sous le namespace du component : \local_todolist\manager.

<?php // public/local/todolist/classes/manager.php namespace local_todolist; use core\di; // (non requis ici, mais rappel : DI dispo en 5.x) defined('MOODLE_INTERNAL') || die(); /** * Couche d'accès aux données des tâches personnelles. */ class manager { /** Nom de la table (sans préfixe mdl_). */ private const TABLE = 'local_todolist'; /** * Retourne les tâches d'un utilisateur, non terminées d'abord, puis par date. * * @param int $userid * @return array tableau d'objets stdClass indexés par id */ public static function get_user_tasks(int $userid): array { global $DB; return $DB->get_records( self::TABLE, ['userid' => $userid], 'completed ASC, timemodified DESC' ); } /** * Récupère une tâche en s'assurant qu'elle appartient bien à l'utilisateur. * * @throws \dml_missing_record_exception si absente ou pas au bon propriétaire. */ public static function get_own_task(int $id, int $userid): \stdClass { global $DB; return $DB->get_record( self::TABLE, ['id' => $id, 'userid' => $userid], '*', MUST_EXIST // lève une exception si rien : sécurité contre l'accès à la tâche d'autrui. ); } /** * Crée une tâche pour l'utilisateur. Applique la limite « maxtasks ». * * @return int l'id créé * @throws \moodle_exception si la limite est atteinte */ public static function create(int $userid, string $name, int $duedate = 0): int { global $DB; $max = (int) get_config('local_todolist', 'maxtasks'); if ($max > 0) { $count = $DB->count_records(self::TABLE, ['userid' => $userid]); if ($count >= $max) { throw new \moodle_exception('error'); // simplifié ; en réel : chaîne dédiée. } } $now = time(); $record = (object) [ 'userid' => $userid, 'name' => $name, 'completed' => 0, 'duedate' => $duedate, // colonne ajoutée en v2 (ignorée en v1, cf. §11) 'timecreated' => $now, 'timemodified' => $now, ]; return $DB->insert_record(self::TABLE, $record); } /** * Met à jour le libellé (et l'échéance) d'une tâche de l'utilisateur. */ public static function update(int $id, int $userid, string $name, int $duedate = 0): void { global $DB; $task = self::get_own_task($id, $userid); // vérifie la propriété. $task->name = $name; $task->duedate = $duedate; $task->timemodified = time(); $DB->update_record(self::TABLE, $task); } /** * Bascule l'état terminé/à faire. */ public static function toggle(int $id, int $userid): void { global $DB; $task = self::get_own_task($id, $userid); $task->completed = $task->completed ? 0 : 1; $task->timemodified = time(); $DB->update_record(self::TABLE, $task); } /** * Supprime une tâche de l'utilisateur. */ public static function delete(int $id, int $userid): void { global $DB; // delete_records avec userid : on ne peut pas supprimer la tâche d'un autre. $DB->delete_records(self::TABLE, ['id' => $id, 'userid' => $userid]); } }

Ce fichier illustre tout ce qui compte sur la couche données :

  • get_records / get_record / insert_record / update_record / delete_records / count_records : les méthodes DML de base (catalogue complet en Partie 5 et annexe cheatsheet).
  • Les placeholders sous forme de tableau associatif (['userid' => $userid]) : jamais de concaténation de SQL. C’est la protection contre l’injection.
  • MUST_EXIST dans get_own_task : combiné au filtre userid, il garantit qu’un utilisateur ne peut pas charger la tâche d’un autre en trafiquant l’id dans l’URL (IDOR — la faille d’autorisation classique). La sécurité n’est pas seulement dans la capability : elle est aussi dans le filtre userid de chaque requête.
  • Les méthodes sont statiques ici pour la lisibilité du tutoriel ; une variante injectable (via core\di) est possible pour faciliter les tests (Partie 11).

⚠️ Piège de sécurité majeur : require_capability('local/todolist:manageentries') dit seulement « cet utilisateur peut gérer des tâches » — pas « cette tâche-ci est la sienne ». Sans le filtre userid dans chaque requête, Alice pourrait éditer la tâche de Bob avec edit.php?id=<id de Bob>. Autorisation ≠ appartenance : vérifiez toujours les deux.


8. Le formulaire : classes/form/todo_form.php

Pour ajouter/éditer une tâche, on utilise la Form API (moodleform) : validation, jetons anti-CSRF (sesskey), rendu cohérent avec le thème — gratuitement.

<?php // public/local/todolist/classes/form/todo_form.php namespace local_todolist\form; defined('MOODLE_INTERNAL') || die(); require_once($GLOBALS['CFG']->libdir . '/formslib.php'); /** * Formulaire d'ajout / édition d'une tâche. */ class todo_form extends \moodleform { protected function definition(): void { $mform = $this->_form; // Champ caché : l'id (0 = création). $mform->addElement('hidden', 'id', 0); $mform->setType('id', PARAM_INT); // Intitulé de la tâche. $mform->addElement('text', 'name', get_string('taskname', 'local_todolist'), ['size' => 60]); $mform->setType('name', PARAM_TEXT); $mform->addRule('name', null, 'required', null, 'client'); $mform->addRule('name', get_string('maximumchars', '', 255), 'maxlength', 255, 'client'); $mform->addHelpButton('name', 'taskname', 'local_todolist'); // Date d'échéance (facultative) — utilisée pleinement à partir de la v2. $mform->addElement('date_selector', 'duedate', get_string('duedate', 'local_todolist'), ['optional' => true]); $mform->addHelpButton('duedate', 'duedate', 'local_todolist'); // Boutons Enregistrer / Annuler (le second renvoie vers la liste). $this->add_action_buttons(true, get_string('savechanges')); } /** * Validation serveur (en plus des règles client). */ public function validation($data, $files): array { $errors = parent::validation($data, $files); if (trim($data['name']) === '') { $errors['name'] = get_string('required'); } return $errors; } }

Ce que la Form API vous offre sans effort :

  • setType(..., PARAM_TEXT/PARAM_INT) : le nettoyage des entrées est déclaratif. Quand vous lirez $data = $mform->get_data(), les valeurs sont déjà typées et nettoyées.
  • addRule('name', ..., 'required') : validation, côté client et serveur.
  • add_action_buttons : les boutons standard, avec le sesskey intégré — la protection CSRF est automatique pour tout formulaire moodleform.
  • date_selector avec optional : un sélecteur de date qui renvoie 0 s’il est décoché. Parfait pour notre échéance facultative.

💡 Pour un dev React : moodleform joue le rôle combiné de React Hook Form + Zod + votre design system : définition déclarative des champs, validation client + serveur, typage/sanitation, et rendu déjà stylé par le thème. Vous n’écrivez pas de HTML de formulaire à la main — vous décrivez des champs, Moodle produit le markup accessible.


9. Les pages : le pattern sécurisé (index.php, edit.php)

9.1 index.php — lister et agir

C’est la page principale. Elle illustre le pattern de page sécurisée que vous réutiliserez partout dans Moodle.

<?php // public/local/todolist/index.php require(__DIR__ . '/../../config.php'); // 1. bootstrap Moodle (définit $CFG, $DB, $USER, $PAGE, $OUTPUT…) use local_todolist\manager; require_login(); // 2. exige un utilisateur connecté $context = context_system::instance(); // 3. contexte de raisonnement des permissions require_capability('local/todolist:manageentries', $context); // 4. autorisation $PAGE->set_context($context); // 5. configuration de la page $PAGE->set_url(new moodle_url('/local/todolist/index.php')); $PAGE->set_pagelayout('standard'); $PAGE->set_title(get_string('mytodos', 'local_todolist')); $PAGE->set_heading(get_string('mytodos', 'local_todolist')); // --- 6. Traitement des actions mutantes (toggle / delete), protégées par sesskey --- $action = optional_param('action', '', PARAM_ALPHA); $taskid = optional_param('id', 0, PARAM_INT); if ($action && $taskid) { require_sesskey(); // protection CSRF : rejette toute requête sans jeton valide. if ($action === 'toggle') { manager::toggle($taskid, $USER->id); } else if ($action === 'delete') { manager::delete($taskid, $USER->id); } // Redirection post-action (pattern PRG : évite le double-submit au rechargement). redirect(new moodle_url('/local/todolist/index.php')); } // --- 7. Récupération des données --- $tasks = manager::get_user_tasks($USER->id); // --- 8. Rendu via l'Output API --- $output = $PAGE->get_renderer('local_todolist'); $renderable = new \local_todolist\output\todo_list($tasks); echo $OUTPUT->header(); echo $output->render($renderable); echo $OUTPUT->footer();

Décortiquons ce squelette — apprenez-le par cœur, il est identique dans 90 % des pages Moodle :

  1. require(config.php) amorce Moodle : sans lui, rien n’existe ($DB, $USER, $PAGE…). Le chemin relatif remonte jusqu’à la racine.
  2. require_login() garantit une session authentifiée (et redirige vers la page de connexion sinon).
  3. context_system::instance() : le contexte dans lequel on évalue les droits (ici, le site).
  4. require_capability(...) : l’autorisation. Absente ⇒ page d’erreur, pas de fuite.
  5. $PAGE->set_* : URL canonique, layout, titre, en-tête. set_url avant tout $OUTPUT.
  6. Actions mutantes : lues via optional_param typé, protégées par require_sesskey(), puis redirect() (pattern Post/Redirect/Get : on ne rejoue pas une suppression au F5).
  7. Données via le manager (jamais de DML brut ici : la page orchestre, le manager accède).
  8. Rendu : on obtient le renderer du plugin, on construit un renderable, on encadre par header()/footer(). Aucun echo '<div>…' de HTML métier.

⚠️ Piège : require_sesskey() protège les actions GET que nous utilisons ici pour la simplicité pédagogique (liens ?action=delete&id=…&sesskey=…). En production, une suppression gagnerait à passer par un POST (bouton dans un <form>) et une confirmation ; on peut aussi ajouter un écran de confirmation via $OUTPUT->confirm(...). Le principe reste : toute mutation exige un jeton sesskey valide.

9.2 edit.php — ajouter / modifier via le formulaire

<?php // public/local/todolist/edit.php require(__DIR__ . '/../../config.php'); use local_todolist\manager; use local_todolist\form\todo_form; require_login(); $context = context_system::instance(); require_capability('local/todolist:manageentries', $context); $id = optional_param('id', 0, PARAM_INT); // 0 = création, sinon édition. $PAGE->set_context($context); $PAGE->set_url(new moodle_url('/local/todolist/edit.php', ['id' => $id])); $PAGE->set_pagelayout('standard'); $title = $id ? get_string('edittask', 'local_todolist') : get_string('addtask', 'local_todolist'); $PAGE->set_title($title); $PAGE->set_heading($title); $mform = new todo_form(); $listurl = new moodle_url('/local/todolist/index.php'); // Pré-remplissage en cas d'édition (avec vérification d'appartenance). if ($id) { $task = manager::get_own_task($id, $USER->id); // lève une exception si pas à nous. $mform->set_data($task); } if ($mform->is_cancelled()) { redirect($listurl); } else if ($data = $mform->get_data()) { // $data est déjà typé/nettoyé grâce à setType(). $duedate = !empty($data->duedate) ? (int) $data->duedate : 0; if ($data->id) { manager::update($data->id, $USER->id, $data->name, $duedate); } else { manager::create($USER->id, $data->name, $duedate); } redirect($listurl, get_string('changessaved'), null, \core\output\notification::NOTIFY_SUCCESS); } echo $OUTPUT->header(); echo $OUTPUT->heading($title); $mform->display(); echo $OUTPUT->footer();

Le triptyque is_cancelled()get_data()display() est le flux canonique d’une page de formulaire Moodle :

  • is_cancelled() : l’utilisateur a cliqué « Annuler » → on redirige.
  • get_data() renvoie les données validées et nettoyées si le formulaire a été soumis correctement, sinon null. C’est ici qu’on écrit en base.
  • Sinon (premier affichage ou erreurs de validation), display() rend le formulaire (avec les messages d’erreur le cas échéant).

10. L’affichage : renderable + renderer + template Mustache

Moodle sépare strictement quoi afficher (le PHP prépare des données) de comment l’afficher (le template Mustache produit le HTML). C’est l’Output API (Partie 5.4). Trois pièces.

10.1 Le renderable : classes/output/todo_list.php

Il prépare le contexte de données que le template consommera. Il implémente templatable (transforme l’objet en tableau pour Mustache) et renderable.

<?php // public/local/todolist/classes/output/todo_list.php namespace local_todolist\output; use renderable; use templatable; use renderer_base; use moodle_url; defined('MOODLE_INTERNAL') || die(); class todo_list implements renderable, templatable { /** @var array tâches de l'utilisateur */ private array $tasks; public function __construct(array $tasks) { $this->tasks = $tasks; } /** * Prépare le contexte pour le template Mustache. * * @param renderer_base $output * @return array */ public function export_for_template(renderer_base $output): array { $items = []; foreach ($this->tasks as $task) { $items[] = [ 'id' => $task->id, 'name' => format_string($task->name), // échappement + filtres : sortie sûre. 'completed' => (bool) $task->completed, 'toggleurl' => (new moodle_url('/local/todolist/index.php', ['action' => 'toggle', 'id' => $task->id, 'sesskey' => sesskey()]))->out(false), 'editurl' => (new moodle_url('/local/todolist/edit.php', ['id' => $task->id]))->out(false), 'deleteurl' => (new moodle_url('/local/todolist/index.php', ['action' => 'delete', 'id' => $task->id, 'sesskey' => sesskey()]))->out(false), ]; } return [ 'hastasks' => !empty($items), 'tasks' => $items, 'addurl' => (new moodle_url('/local/todolist/edit.php'))->out(false), ]; } }

Deux points de sécurité cruciaux :

  • format_string($task->name) échappe et applique les filtres à la sortie. On n’insère jamais de donnée utilisateur brute dans le HTML. (Pour du contenu riche multiligne, ce serait format_text().)
  • Les URL d’action embarquent sesskey() : c’est la moitié « émission du jeton » dont require_sesskey() est la « vérification ».

10.2 Le renderer : classes/output/renderer.php

Il fait le pont entre le renderable et le template.

<?php // public/local/todolist/classes/output/renderer.php namespace local_todolist\output; defined('MOODLE_INTERNAL') || die(); class renderer extends \plugin_renderer_base { /** * Rend la liste des tâches. */ public function render_todo_list(todo_list $renderable): string { $context = $renderable->export_for_template($this); return $this->render_from_template('local_todolist/todo_list', $context); } }

render_from_template('local_todolist/todo_list', $context) charge templates/todo_list.mustache du plugin et l’alimente avec le contexte. La convention de nommage component/template est la résolution qui permet aussi aux thèmes de surcharger ce template (Partie 8).

10.3 Le template : templates/todo_list.mustache

{{! public/local/todolist/templates/todo_list.mustache Contexte attendu : * hastasks bool * addurl string * tasks[] { id, name, completed bool, toggleurl, editurl, deleteurl } }} <div class="local-todolist"> <div class="mb-3"> <a href="{{addurl}}" class="btn btn-primary"> {{#pix}}t/add, core{{/pix}} {{#str}}addtask, local_todolist{{/str}} </a> </div> {{#hastasks}} <ul class="list-group"> {{#tasks}} <li class="list-group-item d-flex justify-content-between align-items-center {{#completed}}list-group-item-success{{/completed}}"> <span class="{{#completed}}text-decoration-line-through text-muted{{/completed}}"> {{name}} </span> <span class="btn-group btn-group-sm" role="group"> <a href="{{toggleurl}}" class="btn btn-outline-secondary"> {{#completed}}{{#str}}markundone, local_todolist{{/str}}{{/completed}} {{^completed}}{{#str}}markdone, local_todolist{{/str}}{{/completed}} </a> <a href="{{editurl}}" class="btn btn-outline-secondary">{{#str}}edit, core{{/str}}</a> <a href="{{deleteurl}}" class="btn btn-outline-danger">{{#str}}delete, core{{/str}}</a> </span> </li> {{/tasks}} </ul> {{/hastasks}} {{^hastasks}} <div class="alert alert-info">{{#str}}notasks, local_todolist{{/str}}</div> {{/hastasks}} </div>

Ce template montre l’essentiel de Mustache dans Moodle :

  • Sections {{#tasks}}…{{/tasks}} (boucle) et inversions {{^hastasks}}…{{/hastasks}} (« si vide »).
  • Les helpers Moodle : {{#str}}clé, component{{/str}} (chaîne de langue), {{#pix}}icône, component{{/pix}} (icône). Ils gardent le template internationalisé et thémable.
  • Les classes Bootstrap 5.3 (list-group, btn, text-decoration-line-through) : le thème Boost les stylise déjà. Vous ne réinventez pas le CSS.

💡 Pour un dev React : le trio renderable → renderer → mustache est exactement une séparation view-model → composant. export_for_template() est votre fonction qui transforme l’état serveur en props sérialisables ; le .mustache est un composant sans logique (Mustache est volontairement « logic-less »), comme un composant de présentation pur. La grande différence : le rendu est serveur, et l’échappement de sortie (format_string) est votre responsabilité, pas celle du moteur (React échappe par défaut ; Mustache aussi pour {{ }}, mais les données doivent déjà être passées par les filtres Moodle).


11. Brancher la navigation : lib.php

Notre page existe mais rien n’y mène. Ajoutons une entrée dans le menu utilisateur. Les plugins locaux exposent des callbacks dans lib.php que le core appelle en construisant la navigation.

<?php // public/local/todolist/lib.php defined('MOODLE_INTERNAL') || die(); /** * Ajoute « Ma liste de tâches » au menu utilisateur (avatar → …). * * Callback appelé par le core lors de la construction du menu utilisateur. * * @param navigation_node $navigation * @param stdClass $user * @param context_user $usercontext * @param stdClass $course * @param context_course $coursecontext */ function local_todolist_extend_navigation_user_settings( navigation_node $navigation, stdClass $user, context_user $usercontext, stdClass $course, context_course $coursecontext ): void { global $USER; // On n'ajoute l'entrée que pour l'utilisateur lui-même, s'il a la permission. if ($USER->id != $user->id) { return; } if (!has_capability('local/todolist:manageentries', context_system::instance())) { return; } $url = new moodle_url('/local/todolist/index.php'); $node = navigation_node::create( get_string('mytodos', 'local_todolist'), $url, navigation_node::TYPE_SETTING, null, 'local_todolist', new pix_icon('i/checkedcircle', '') ); $navigation->add_node($node); }

Deux choses à retenir :

  • On filtre par capability et par identité ($USER->id == $user->id) : la navigation elle-même ne doit pas fuir de lien vers une ressource interdite.
  • Le nom du callback est frankenstyle : local_todolist_extend_navigation_user_settings. C’est la convention qui permet au core de le trouver. Il existe des callbacks jumeaux pour d’autres emplacements (_extend_navigation, _extend_settings_navigation, etc.).

📚 Aller plus loin : Moodle migre progressivement la navigation vers le système de hooks (chapitre 5.6). En 5.2, ces callbacks lib.php restent pleinement supportés et sont le plus simple pour débuter. Pour un plugin moderne visant le long terme, regardez aussi les hooks de navigation (\core\hook\navigation\…) documentés sur moodledev.io  — le principe (filtrer par capability, ajouter un nœud) est identique.

⚠️ Piège : après avoir ajouté/modifié un callback de navigation, purgez les caches — la structure de navigation et la liste des fonctions de plugin sont mises en cache. « Mon lien n’apparaît pas » = 9 fois sur 10 un cache.


12. Installation

Tout est en place pour la version 1. On installe :

  1. Le dossier public/local/todolist/ est présent avec tous les fichiers ci-dessus sauf la référence à duedate dans install.xml (voir la nuance §13 — pour une première install propre de la v1, la colonne duedate n’existe pas encore ; le code qui la mentionne dans manager/edit la traitera en v2).
  2. Connecté en admin, visitez n’importe quelle page : Moodle détecte le nouveau code et propose la page « Mises à jour de la base de données ». Validez : il joue install.xml (création de la table) et enregistre db/access.php.
    • En ligne de commande (recommandé en dev) : php admin/cli/upgrade.php.
  3. Purgez les caches : php admin/cli/purge_caches.php.
  4. Vérifiez : la page Administration du site → Plugins → Plugins locaux liste « My to-do list », et le menu utilisateur propose « Ma liste de tâches ». Cliquez : la page s’affiche avec « Vous n’avez aucune tâche ». Ajoutez-en une : elle apparaît, marquable et supprimable.

13. La chaîne d’upgrade : ajouter duedate en version 2

Voici le morceau que le chapitre 6.2 promettait : faire évoluer le schéma d’un plugin déjà installé. On veut ajouter la colonne duedate (échéance). La règle d’or :

install.xml décrit l’état final du schéma. db/upgrade.php décrit comment y arriver depuis une version antérieure. Les deux doivent rester cohérents.

Autrement dit : une nouvelle installation lit install.xml (état final, avec duedate) ; une installation existante (qui a déjà la v1 sans duedate) exécute upgrade.php pour rattraper. Si vous ne mettez à jour que l’un des deux, les deux populations divergent.

Étape 1 — mettre à jour install.xml : ajouter le champ duedate (pour les futures installs) et bumper le VERSION de la balise <XMLDB>.

<!-- extrait de public/local/todolist/db/install.xml (v2) --> <TABLE NAME="local_todolist" COMMENT="Tâches personnelles des utilisateurs"> <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="completed" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/> <FIELD NAME="duedate" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="Échéance (timestamp), 0 = aucune"/> <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 / INDEXES inchangés --> </TABLE>

Étape 2 — écrire la migration db/upgrade.php : le core appelle la fonction xmldb_local_todolist_upgrade($oldversion) en lui passant la version actuellement installée. On teste $oldversion par paliers.

<?php // public/local/todolist/db/upgrade.php defined('MOODLE_INTERNAL') || die(); /** * Mises à niveau du schéma de local_todolist. * * @param int $oldversion version actuellement installée * @return bool */ function xmldb_local_todolist_upgrade(int $oldversion): bool { global $DB; $dbman = $DB->get_manager(); // le gestionnaire de schéma XMLDB. // Palier v2 : ajout de la colonne duedate. if ($oldversion < 2026070801) { $table = new xmldb_table('local_todolist'); $field = new xmldb_field( 'duedate', // nom XMLDB_TYPE_INTEGER, // type '10', // longueur null, XMLDB_NOTNULL, // NOT NULL null, '0', // valeur par défaut 'completed' // insérer APRÈS la colonne 'completed' ); // On n'ajoute la colonne que si elle n'existe pas déjà (idempotence défensive). if (!$dbman->field_exists($table, $field)) { $dbman->add_field($table, $field); } // Point de sauvegarde : marque ce palier comme franchi. upgrade_plugin_savepoint(true, 2026070801, 'local', 'todolist'); } // Les futurs paliers viendront ici : if ($oldversion < 2026080100) { … } return true; }

Étape 3 — bumper version.php pour déclencher l’upgrade :

// public/local/todolist/version.php (v2) $plugin->version = 2026070801; // > 2026070800 : Moodle détecte le changement et joue upgrade.php. $plugin->release = 'v2.0';

Étape 4 — jouer l’upgrade : php admin/cli/upgrade.php (ou la page web). Moodle voit 2026070801 > 2026070800, appelle xmldb_local_todolist_upgrade(2026070800), exécute le bloc duedate, enregistre le savepoint, met à jour la version en base. Purgez les caches. La colonne duedate existe désormais, et le date_selector du formulaire (déjà présent) prend tout son sens.

Anatomie d’un bloc d’upgrade — le patron à reproduire pour chaque palier :

⚠️ Piège n°1 — oublier le savepoint. upgrade_plugin_savepoint() valide le palier : si l’upgrade échoue plus loin, Moodle sait quels paliers sont déjà passés et ne les rejoue pas. Sans savepoint, un upgrade interrompu laisse la base dans un état ambigu. Un bloc if ($oldversion < X) = un savepoint à X, toujours.

⚠️ Piège n°2 — modifier install.xml sans écrire le palier (ou l’inverse). Si vous ajoutez duedate seulement dans install.xml, les installs existantes n’auront jamais la colonne. Si vous ne le mettez que dans upgrade.php, les nouvelles installs (qui ne jouent pas les upgrades) ne l’auront pas non plus. Les deux, systématiquement.

⚠️ Piège n°3 — utiliser l’ORM là où il n’existe pas encore. Dans le bloc d’upgrade < 2026070801, la colonne duedate n’existe pas tant que add_field n’a pas tourné. Ne référencez pas duedate dans une requête avant cette ligne. Les migrations se raisonnent dans l’ordre.

💡 Pour un dev React : upgrade.php est exactement un dossier de migrations (Prisma Migrate, Knex, Rails…). $oldversion est la « dernière migration appliquée », chaque if ($oldversion < X) est un fichier de migration, et upgrade_plugin_savepoint est le COMMIT qui inscrit la migration comme appliquée. La seule différence culturelle : ici, l’« état cible » est dupliqué dans install.xml (que Prisma générerait tout seul). C’est le prix de la portabilité multi-SGBD de Moodle.


14. Récapitulatif : la carte du plugin

Vous avez touché à tous les grands sous-systèmes vus en Partie 5 : identité de plugin, schéma XMLDB + migrations, capabilities/contextes, réglages admin, String API, Form API, DML, Output API, navigation. Ce socle est identique pour les types plus riches (bloc, activité) — que nous attaquons dans les chapitres suivants ; ils ajoutent surtout leur propre contrat de fichiers par-dessus ce même fond.


✏️ Exercices

Exercice 1 — Diagnostiquer un lien manquant. Vous ajoutez le callback local_todolist_extend_navigation_user_settings dans lib.php, mais le lien n’apparaît pas dans le menu utilisateur. Le code est correct. Quelles sont les deux causes les plus probables et comment y remédier ?

✅ Solution

(1) Les caches. La liste des fonctions de plugin et la navigation sont mises en cache : php admin/cli/purge_caches.php (ou Développement → Purger les caches). (2) La version non bumpée si vous avez aussi modifié access.php (la capability n’est pas enregistrée, donc has_capability renvoie false et le callback sort tôt). Vérifiez que local/todolist:manageentries existe bien (Utilisateurs → Permissions → Définir les rôles ou via un test) et, au besoin, bumpez version.php puis rejouez l’upgrade. Dans 90 % des cas : c’est le cache.

Exercice 2 — Faille IDOR. Un stagiaire simplifie get_own_task en return $DB->get_record('local_todolist', ['id' => $id], '*', MUST_EXIST); (il retire le filtre userid). La capability est toujours vérifiée. Décrivez précisément l’attaque possible et corrigez.

✅ Solution

Attaque : Alice (utilisatrice légitime, donc elle a local/todolist:manageentries) ouvre edit.php?id=<id d'une tâche de Bob>. Comme la requête ne filtre plus par userid, elle charge, édite et supprime la tâche de Bob. La capability autorise « gérer des tâches », mais n’établit pas l’appartenance de cette tâche : c’est une faille IDOR (Insecure Direct Object Reference). Correction : rétablir le filtre ['id' => $id, 'userid' => $userid] (comme dans le §7) — avec MUST_EXIST, une tentative sur la tâche d’autrui lève une exception. Règle : autorisation ≠ appartenance ; filtrez toujours par le propriétaire.

Exercice 3 — Ajouter un tri par échéance. Après la v2, on veut afficher les tâches non terminées d’abord, puis par échéance croissante (les tâches sans échéance, duedate = 0, en dernier). Modifiez manager::get_user_tasks.

✅ Solution

Le tri SQL doit envoyer duedate = 0 en fin. Une astuce portable : trier d’abord sur un booléen « a une échéance », puis sur duedate :

public static function get_user_tasks(int $userid): array { global $DB; // completed ASC : à faire d'abord. // (duedate = 0) place les sans-échéance après les datées ; puis duedate ASC. $sql = "SELECT * FROM {local_todolist} WHERE userid = :userid ORDER BY completed ASC, (duedate = 0) ASC, duedate ASC, timemodified DESC"; return $DB->get_records_sql($sql, ['userid' => $userid]); }

Notez {local_todolist} (le préfixe est résolu par Moodle) et le placeholder nommé :userid. On passe de get_records à get_records_sql dès que le tri dépasse ce qu’un simple ORDER BY de colonnes permet.

Exercice 4 — Palier d’upgrade v3. On veut, en v3, ajouter un index sur duedate pour accélérer le tri de l’exercice 3. Écrivez le bloc d’upgrade et indiquez les deux autres fichiers à toucher.

✅ Solution

Dans db/upgrade.php, après le palier v2 :

if ($oldversion < 2026070802) { $table = new xmldb_table('local_todolist'); $index = new xmldb_index('duedate', XMLDB_INDEX_NOTUNIQUE, ['duedate']); if (!$dbman->index_exists($table, $index)) { $dbman->add_index($table, $index); } upgrade_plugin_savepoint(true, 2026070802, 'local', 'todolist'); }

Autres fichiers : (1) db/install.xml — ajouter ce même index dans <INDEXES> (état final, pour les nouvelles installs) et bumper son VERSION ; (2) version.php — passer $plugin->version à 2026070802. Puis admin/cli/upgrade.php + purge des caches.

Exercice 5 — Respecter maxtasks. La limite maxtasks est vérifiée dans manager::create. Un utilisateur pourrait-il la contourner ? Où renforcer, et comment améliorer l’expérience (message clair au lieu d’une erreur générique) ?

✅ Solution

Le contournement direct est difficile (toute création passe par create), mais l’expérience est mauvaise : on lève moodle_exception('error'), message générique. Améliorations : (1) définir une chaîne dédiée $string['maxtasksreached'] = 'You have reached the maximum of {$a} tasks.'; et lever new \moodle_exception('maxtasksreached', 'local_todolist', $listurl, $max); ; (2) anticiper côté UI : dans index.php/le renderable, si la limite est atteinte, désactiver le bouton « Ajouter » et afficher un avertissement — on ne compte pas seulement sur l’erreur serveur, on guide l’utilisateur. La vérification serveur reste la source de vérité (ne jamais se fier au seul masquage côté client), mais un bon plugin fait les deux.


🧠 Quiz de révision

Q1. Quel est le rôle exact de db/install.xml par rapport à db/upgrade.php ?

Réponse

install.xml décrit l’état final du schéma et n’est joué qu’à la première installation. upgrade.php décrit comment migrer une installation existante d’une version à l’autre (paliers if ($oldversion < X) + savepoints). Les deux doivent rester cohérents : toute évolution du schéma se met dans les deux.

Q2. Citez, dans l’ordre, les quatre premières lignes du pattern de page sécurisée.

Réponse

require(config.php)require_login() → obtenir le contexte (context_system::instance()) → require_capability(...). Ensuite viennent $PAGE->set_*, le traitement des actions (avec require_sesskey()), puis le rendu.

Q3. Pourquoi require_capability('local/todolist:manageentries') ne suffit-il pas à protéger l’accès à une tâche donnée ?

Réponse

La capability établit l’autorisation (« cet utilisateur peut gérer des tâches ») mais pas l’appartenance (« cette tâche-ci est la sienne »). Sans filtre userid dans la requête, un utilisateur autorisé peut accéder à la tâche d’un autre via l’id de l’URL (faille IDOR). Il faut les deux : capability et filtre par propriétaire.

Q4. À quoi sert upgrade_plugin_savepoint() et que se passe-t-il si on l’oublie ?

Réponse

Il valide un palier d’upgrade : Moodle enregistre que ce palier est franchi et ne le rejouera pas. Si on l’oublie, un upgrade interrompu plus loin laisse la base dans un état ambigu (Moodle ne sait pas quels paliers sont déjà passés) et peut rejouer des opérations. Règle : un bloc if ($oldversion < X) = un savepoint à X.

Q5. Comment une donnée saisie par l’utilisateur (le nom de la tâche) sort-elle en toute sécurité vers le HTML ?

Réponse

Elle est échappée et filtrée à la sortie via format_string($task->name) dans export_for_template() (Output API), puis rendue par Mustache. On ne concatène jamais de HTML brut, et l’entrée a par ailleurs été typée/nettoyée à la saisie (PARAM_TEXT dans le moodleform). Entrée nettoyée et sortie échappée : les deux bouts de la chaîne.

Chapitre suivant : 04 — Tutoriel complet : un bloc (block_progressinfo), où l’on réutilise ce socle (version, capabilities, DML, Mustache) mais sous le contrat du type block : get_content(), configuration par instance, applicable_formats(), un module JS AMD léger, et l’ajout automatique au tableau de bord.