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_todolistde 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 danslocal/todolist/, son component estlocal_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 exigerequire_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
echode HTML brut.- L’upgrade (v2) ajoute une colonne
duedate: on modifieinstall.xml, on écrit le bloc correspondant dansupgrade.phpavec un savepoint, et on bumpeversion.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 pagesindex.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 typelocal), 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 souspublic/local/…,public/mod/…, etc. Pour votre plugin, placez-le donc souspublic/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 surpublic/. 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 :
componentest la vérité absolue du plugin.local_todolist= typelocal+ nomtodolist. 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 dedb/upgrade.php.requiresempê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 (souventmdl_), donnantmdl_local_todolist. Ne mettez jamais le préfixemdl_vous-même — Moodle s’en charge. - Le champ
idavecSEQUENCE="true"est l’auto-incrément obligatoire ; c’est la clé primaire de toute table Moodle. useridest déclaré comme clé étrangère versuser(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 paruserid(« mes tâches »), souvent parcompleted. Indexer ce couple, c’est de la performance élémentaire (Partie 11). - L’attribut
VERSIONde la balise<XMLDB>reflète la version du schéma ; l’éditeur XMLDB le tient à jour.
💡 Pour un dev React :
install.xmlest votre schéma Prisma décrit en XML portable, etdb/upgrade.php(plus loin) est votre dossier de migrations. Moodle joueinstall.xmlune seule fois, à la première installation ; ensuite, toute évolution du schéma passe parupgrade.php. Ne modifiez jamaisinstall.xmlsans é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).archetypes→user→CAP_ALLOW: l’astuce clé. L’archétypeusercorrespond 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.phpn’est (re)lu par Moodle qu’à l’installation et à chaque upgrade (bump deversion.php). Si vous modifiez une capability sans bumper la version, Moodle ne la voit pas. Réflexe : toute modif deaccess.php⇒ nouveauversion.
💡 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:manageentriessur ce contexte ? » viahas_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é
pluginnameest 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/maxtasksdéfinit où la valeur est stockée. On la relira avecget_config('local_todolist', 'maxtasks'). PARAM_INTcontraint le type : la Form API des réglages valide l’entrée. (Catalogue complet desPARAM_*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.phpest 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_EXISTdansget_own_task: combiné au filtreuserid, il garantit qu’un utilisateur ne peut pas charger la tâche d’un autre en trafiquant l’iddans l’URL (IDOR — la faille d’autorisation classique). La sécurité n’est pas seulement dans la capability : elle est aussi dans le filtreuseridde 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 filtreuseriddans chaque requête, Alice pourrait éditer la tâche de Bob avecedit.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 lesesskeyintégré — la protection CSRF est automatique pour tout formulairemoodleform.date_selectoravecoptional: un sélecteur de date qui renvoie0s’il est décoché. Parfait pour notre échéance facultative.
💡 Pour un dev React :
moodleformjoue 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 :
require(config.php)amorce Moodle : sans lui, rien n’existe ($DB,$USER,$PAGE…). Le chemin relatif remonte jusqu’à la racine.require_login()garantit une session authentifiée (et redirige vers la page de connexion sinon).context_system::instance(): le contexte dans lequel on évalue les droits (ici, le site).require_capability(...): l’autorisation. Absente ⇒ page d’erreur, pas de fuite.$PAGE->set_*: URL canonique, layout, titre, en-tête.set_urlavant tout$OUTPUT.- Actions mutantes : lues via
optional_paramtypé, protégées parrequire_sesskey(), puisredirect()(pattern Post/Redirect/Get : on ne rejoue pas une suppression au F5). - Données via le manager (jamais de DML brut ici : la page orchestre, le manager accède).
- Rendu : on obtient le renderer du plugin, on construit un renderable, on encadre par
header()/footer(). Aucunecho '<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 jetonsesskeyvalide.
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, sinonnull. 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 seraitformat_text().)- Les URL d’action embarquent
sesskey(): c’est la moitié « émission du jeton » dontrequire_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.mustacheest 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.phprestent 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 :
- Le dossier
public/local/todolist/est présent avec tous les fichiers ci-dessus sauf la référence àduedatedansinstall.xml(voir la nuance §13 — pour une première install propre de la v1, la colonneduedaten’existe pas encore ; le code qui la mentionne dansmanager/editla traitera en v2). - 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 enregistredb/access.php.- En ligne de commande (recommandé en dev) :
php admin/cli/upgrade.php.
- En ligne de commande (recommandé en dev) :
- Purgez les caches :
php admin/cli/purge_caches.php. - 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.xmldécrit l’état final du schéma.db/upgrade.phpdé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 blocif ($oldversion < X)= un savepoint àX, toujours.
⚠️ Piège n°2 — modifier
install.xmlsans écrire le palier (ou l’inverse). Si vous ajoutezduedateseulement dansinstall.xml, les installs existantes n’auront jamais la colonne. Si vous ne le mettez que dansupgrade.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 colonneduedaten’existe pas tant queadd_fieldn’a pas tourné. Ne référencez pasduedatedans une requête avant cette ligne. Les migrations se raisonnent dans l’ordre.
💡 Pour un dev React :
upgrade.phpest exactement un dossier de migrations (Prisma Migrate, Knex, Rails…).$oldversionest la « dernière migration appliquée », chaqueif ($oldversion < X)est un fichier de migration, etupgrade_plugin_savepointest leCOMMITqui inscrit la migration comme appliquée. La seule différence culturelle : ici, l’« état cible » est dupliqué dansinstall.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
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
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
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.