Skip to Content
MoodlePartie 5 — Architecture du cœur de MoodleFichiers, chaînes de langue, configuration

Chapitre 05 — Fichiers, chaînes de langue, configuration

Version de référence : Moodle 5.2 (PHP 8.3+)

Ce chapitre couvre trois API transversales que vous utiliserez dans chaque plugin Moodle, quel que soit son type : la File API (stockage, adressage et service des fichiers), la String API (internationalisation) et l’API de configuration (réglages de plugins et pages d’administration). Ces trois sujets sont regroupés parce qu’ils partagent une philosophie commune : jamais d’accès direct. On ne lit jamais un fichier à un chemin disque, on n’écrit jamais un libellé en dur, on ne stocke jamais un réglage dans une variable globale improvisée. Tout passe par des API qui garantissent sécurité, portabilité et traduisibilité.

Mini-sommaire


PARTIE A — LA FILE API

1. Concepts fondamentaux : le stockage adressé par contenu

1.1 Les fichiers ne sont JAMAIS sur disque à un chemin devinable

Première chose à désapprendre si vous venez du monde « uploads dans /public/uploads/ » : dans Moodle, aucun fichier utilisateur n’est stocké à un chemin qui reflète son nom ou son emplacement logique. Quand un enseignant dépose cours-magistral.pdf dans une ressource, ce PDF n’existe nulle part sous ce nom sur le disque.

À la place, Moodle utilise un stockage adressé par contenu (content-addressable storage) dans le répertoire moodledata/filedir :

  1. Le contenu binaire du fichier est haché avec SHA-1 → c’est le contenthash.
  2. Le fichier est écrit à un chemin dérivé du hash : les deux premiers caractères, puis les deux suivants, puis le hash complet.
contenthash = 6a204bd89f3c8348afd5c77c717a097a44e95e34 moodledata/ └── filedir/ └── 6a/ └── 20/ └── 6a204bd89f3c8348afd5c77c717a097a44e95e34 ← le contenu brut, sans extension
  1. Toutes les métadonnées (nom, emplacement logique, propriétaire, type MIME, dates…) vivent dans la table de base de données files.

Ce découpage a des conséquences majeures :

  • Dédoublonnage automatique : si 500 étudiants déposent le même PDF de 40 Mo, le contenu n’est stocké qu’une seule fois sur disque. Il y a 500 lignes dans la table files (500 « fichiers » logiques), mais un seul blob physique. Deux fichiers de même contenu ont le même contenthash, donc le même chemin.
  • Sécurité : impossible de deviner l’URL d’un fichier ou d’y accéder en contournant Moodle. Le serveur web ne sert jamais moodledata directement (c’est même une exigence d’installation : moodledata doit être hors du docroot ou protégé).
  • Immutabilité : un blob n’est jamais modifié. « Modifier » un fichier = créer un nouveau blob avec un nouveau hash + mettre à jour la ligne de métadonnées. La suppression physique du blob n’a lieu que lorsque plus aucune ligne files ne le référence (nettoyage par tâche planifiée).

💡 Pour un dev React : c’est exactement le modèle des objets Git (.git/objects/6a/204bd8... : contenu adressé par SHA-1, dédupliqué, immuable) combiné au modèle S3 + CDN à URL signées : le blob vit dans un backend opaque, et l’accès public passe par un endpoint applicatif qui vérifie les droits avant de servir — comme une URL signée S3, sauf que la « signature » est ici la session Moodle + un contrôle de capability à chaque requête.

⚠️ Piège : ne lisez et n’écrivez jamais directement dans moodledata/filedir, même « juste pour déboguer en local ». Le chemin exact est un détail d’implémentation du file system backend : Moodle supporte des backends alternatifs (stockage objet S3 via tool_objectfs, par exemple) où le blob n’est même pas sur le disque local. Toute manipulation passe par l’API.

1.2 Le sextuplé d’adressage : où est « logiquement » un fichier ?

Puisque le disque ne connaît que des hashs, c’est la table files qui répond à la question « quels fichiers appartiennent à quoi ? ». Chaque fichier logique est identifié de façon unique par la combinaison de six dimensions (souvent appelée « le quintuplé » dans la doc historique, mais il y a bien six champs) :

DimensionTypeRôle
contextidint dans l’arbre des contextes (cf. chapitre sur les contextes) : contexte de module, de cours, d’utilisateur, système…
componentstringQui possède le fichier, en frankenstyle : mod_folder, mod_assign, course, user, block_html
fileareastringQuelle zone fonctionnelle au sein du composant : content, intro, submission_files, draft, summary
itemidintQuel élément dans la zone : id d’un post de forum, d’une soumission, d’un brouillon… 0 si la zone n’a qu’un seul « élément ».
filepathstringChemin de sous-répertoire virtuel, toujours encadré de / : /, /images/, /docs/2026/.
filenamestringNom du fichier : rapport.pdf.

Quelques exemples concrets pour ancrer l’intuition :

# Un fichier déposé dans une activité « Dossier » (mod_folder) : contextid = 812 (contexte du module folder), component = 'mod_folder', filearea = 'content', itemid = 0, filepath = '/', filename = 'sujet-tp3.pdf' # Une image dans la description (intro) d'un devoir : contextid = 950, component = 'mod_assign', filearea = 'intro', itemid = 0, filepath = '/', filename = 'schema.png' # Un fichier en cours d'édition dans un formulaire (brouillon) : contextid = 5 (contexte UTILISATEUR de celui qui édite), component = 'user', filearea = 'draft', itemid = 947210043 (draftid aléatoire), filepath = '/', filename = 'schema.png' # Une image dans le résumé d'un cours : contextid = 133 (contexte du cours), component = 'course', filearea = 'summary', itemid = 0, filepath = '/', filename = 'banniere.jpg' # Fichier de soumission d'un étudiant dans un devoir : contextid = 950, component = 'assignsubmission_file', filearea = 'submission_files', itemid = 42 (id de la soumission), filepath = '/', filename = 'devoir.docx'

Remarquez la logique : itemid sert quand une même zone contient plusieurs « paquets » de fichiers indépendants (un itemid par soumission, par post de forum…). Quand la zone est unique par contexte (l’intro d’un module, le résumé d’un cours), itemid vaut 0.

1.3 Le fichier « . » : les répertoires sont aussi des lignes

Détail qui surprend : les répertoires sont matérialisés dans la table files par des lignes dont filename vaut . (un point). Le répertoire racine de toute zone de fichiers est la ligne filepath = '/', filename = '.'. Un sous-répertoire /images/ est la ligne filepath = '/images/', filename = '.'.

Ces lignes ont un contenthash particulier : le SHA-1 de la chaîne vide (da39a3ee5e6b4b0d3255bfef95601890afd80709) et filesize = 0. C’est pour cela que presque tous les parcours de fichiers commencent par « exclure les répertoires » — on le verra avec get_area_files().

1.4 La table files : les colonnes qui comptent

-- Extrait simplifié de la structure de mdl_files id BIGINT -- clé primaire contenthash CHAR(40) -- SHA-1 du contenu → localise le blob dans filedir pathnamehash CHAR(40) -- SHA-1 de "/contextid/component/filearea/itemid/filepath/filename" -- → index UNIQUE, permet de retrouver UN fichier en une requête contextid BIGINT component VARCHAR(100) filearea VARCHAR(50) itemid BIGINT filepath VARCHAR(255) filename VARCHAR(255) userid BIGINT -- qui a déposé le fichier (nullable) filesize BIGINT mimetype VARCHAR(100) status BIGINT source TEXT -- provenance (repository d'origine, etc.) author VARCHAR(255) license VARCHAR(255) timecreated BIGINT -- timestamps Unix, comme partout dans Moodle timemodified BIGINT sortorder BIGINT referencefileid BIGINT -- pour les fichiers "alias" pointant vers un repository externe

Le pathnamehash est l’astuce de performance : plutôt que d’indexer six colonnes, Moodle hache la concaténation du sextuplé et pose un index unique dessus. get_file() fait une seule recherche sur ce hash.

📚 Aller plus loin : la référence complète de la File API est sur moodledev.io/docs/apis/subsystems/files . Voir aussi la page « File System API » pour les backends de stockage alternatifs.


2. file_storage et stored_file : manipuler les fichiers en PHP

Deux classes structurent tout :

  • file_storage : le service singleton, façade de toute l’API. On l’obtient via get_file_storage().
  • stored_file : représente un fichier logique (une ligne de files). C’est par ses méthodes qu’on lit le contenu, le nom, le type MIME, etc.

2.1 Lister les fichiers d’une zone : get_area_files()

<?php // Toujours commencer par obtenir le service de stockage. $fs = get_file_storage(); // Contexte du module (on suppose $cm disponible, cf. chapitre pages/modules). $context = context_module::instance($cm->id); // Récupérer TOUS les fichiers d'une zone. // Signature : get_area_files($contextid, $component, $filearea, // $itemid = false, $sort = 'itemid, filepath, filename', // $includedirs = true, ...) $files = $fs->get_area_files( $context->id, // le contexte propriétaire 'mod_folder', // le composant (frankenstyle) 'content', // la zone de fichiers 0, // itemid ; false = tous les itemids de la zone 'filepath, filename', // ordre de tri SQL false // false = EXCLURE les répertoires (les lignes ".") ); foreach ($files as $file) { // $file est un stored_file. echo $file->get_filepath() . $file->get_filename() . ' (' . display_size($file->get_filesize()) . ')' . PHP_EOL; }

⚠️ Piège : le sixième paramètre $includedirs vaut true par défaut. Si vous l’oubliez, votre boucle verra passer les lignes « répertoire » (filename === '.', taille 0) et vous afficherez des entrées fantômes ou tenterez d’ouvrir un contenu vide. Réflexe : soit passer false, soit tester if ($file->is_directory()) { continue; } en tête de boucle. Les deux se voient dans le code du noyau.

2.2 Récupérer UN fichier précis : get_file()

<?php $fs = get_file_storage(); // Le sextuplé complet identifie un fichier de façon unique. $file = $fs->get_file( $context->id, // contextid 'mod_folder', // component 'content', // filearea 0, // itemid '/', // filepath (toujours avec les slashs !) 'sujet-tp3.pdf' // filename ); if ($file === false) { // get_file renvoie false si le fichier n'existe pas — toujours tester. throw new moodle_exception('filenotfound', 'error'); } // Variante : si vous connaissez déjà le pathnamehash (rare) : // $file = $fs->get_file_by_hash($pathnamehash);

2.3 Créer des fichiers : create_file_from_*

Trois créateurs principaux, qui prennent tous un tableau/objet de métadonnées (le sextuplé, au minimum) :

<?php $fs = get_file_storage(); $context = context_module::instance($cm->id); // Le "file record" : les métadonnées du futur fichier. $filerecord = [ 'contextid' => $context->id, 'component' => 'mod_monmodule', 'filearea' => 'export', 'itemid' => 0, 'filepath' => '/', 'filename' => 'export-notes.csv', // Champs optionnels utiles : 'userid' => $USER->id, 'mimetype' => 'text/csv', // sinon deviné depuis l'extension 'author' => fullname($USER), 'license' => 'allrightsreserved', ]; // 1) Depuis une chaîne PHP (génération à la volée) : $csv = "prenom;nom;note\nAda;Lovelace;20\n"; $file = $fs->create_file_from_string($filerecord, $csv); // 2) Depuis un fichier temporaire sur disque (import, upload traité manuellement) : // $file = $fs->create_file_from_pathname($filerecord, '/tmp/import.csv'); // 3) Depuis un autre stored_file (copie logique — le blob n'est PAS dupliqué, // seule une nouvelle ligne de métadonnées est créée, même contenthash) : // $file = $fs->create_file_from_storedfile($filerecord, $sourcefile);

Ce troisième créateur illustre bien le modèle content-addressable : « copier » un fichier de 2 Go est instantané, c’est une insertion SQL.

⚠️ Piège : créer un fichier avec un sextuplé qui existe déjà lève une exception stored_file_creation_exception (« file exists »). Si votre code peut être rejoué (tâche planifiée, régénération d’export), supprimez d’abord l’ancien fichier ($old = $fs->get_file(...); if ($old) { $old->delete(); }) ou toute la zone (delete_area_files).

2.4 Les méthodes de stored_file

<?php /** @var stored_file $file */ // --- Métadonnées --- $file->get_filename(); // 'rapport.pdf' $file->get_filepath(); // '/images/' $file->get_filesize(); // taille en octets (int) $file->get_mimetype(); // 'application/pdf' $file->get_contenthash(); // le SHA-1 du contenu $file->get_timemodified(); // timestamp Unix $file->get_userid(); // id du déposant (peut être null) $file->get_itemid(); $file->get_contextid(); $file->is_directory(); // true pour les lignes "." $file->is_valid_image(); // pratique avant de générer un <img> // --- Contenu --- $content = $file->get_content(); // TOUT le contenu en mémoire — attention aux gros fichiers ! $file->copy_content_to('/tmp/travail.pdf'); // copie vers un chemin disque (streaming, sûr pour les gros fichiers) $handle = $file->get_content_file_handle(); // ressource de flux PHP (fread/stream_copy_to_stream) $file->copy_content_to_temp(); // copie vers un fichier temp Moodle, renvoie le chemin // --- Cycle de vie --- $file->delete(); // supprime la LIGNE de métadonnées ; le blob physique n'est // purgé que plus tard, s'il n'est plus référencé par personne. $file->rename('/nouveaudossier/', 'nouveau-nom.pdf'); // déplace/renomme (métadonnées seulement)

⚠️ Piège : get_content() charge l’intégralité du fichier en RAM. Sur une vidéo de 800 Mo, c’est l’erreur mémoire assurée. Pour transmettre un contenu volumineux à une bibliothèque tierce ou à un process externe, utilisez copy_content_to() ou get_content_file_handle() qui travaillent en flux.

2.5 Nettoyer une zone : delete_area_files()

<?php $fs = get_file_storage(); // Supprimer tous les fichiers d'une zone (ex. dans la fonction de // désinstallation du module, ou avant de régénérer un export). $fs->delete_area_files( $context->id, 'mod_monmodule', 'export', // omis ou null = toutes les zones du composant dans ce contexte 0 // omis ou false = tous les itemids );

C’est ce que fait tout module dans sa fonction *_delete_instance() : les fichiers d’un module supprimé doivent être nettoyés explicitement (la suppression du contexte s’en charge aussi en dernier recours, mais soyez explicite).


3. Servir des fichiers : pluginfile.php et la fonction callback

3.1 Pourquoi un point d’entrée unique ?

Puisque les blobs sont inaccessibles par URL directe, comment un navigateur télécharge-t-il un fichier ? Réponse : toutes les URL de fichiers passent par le script pluginfile.php, qui :

  1. authentifie l’utilisateur (session Moodle) ;
  2. décode l’URL pour retrouver le sextuplé ;
  3. délègue au plugin propriétaire la décision de servir ou non — c’est là que le contrôle d’accès a lieu ;
  4. streame le fichier avec les bons en-têtes HTTP.

Le point 3 est fondamental : seul mod_assign sait qu’un étudiant ne doit pas voir les soumissions des autres, seul mod_quiz sait qu’un fichier de feedback ne doit apparaître qu’après la fermeture du test. Le contrôle d’accès aux fichiers est donc une callback dans le plugin, pas une règle générique.

L’URL type :

https://monsite/pluginfile.php/{contextid}/{component}/{filearea}/{itemid}/{filepath}{filename} # Exemples réels : https://monsite/pluginfile.php/812/mod_folder/content/0/sujet-tp3.pdf https://monsite/pluginfile.php/950/mod_assign/intro/schema.png ← pas d'itemid ! https://monsite/pluginfile.php/5/user/draft/947210043/schema.png

⚠️ Piège : l’itemid est présent ou absent de l’URL selon le composant et la zone. Pour les zones « intro » des modules, la convention est de ne PAS mettre l’itemid dans l’URL (il vaut toujours 0) ; pour les brouillons user/draft, il y est toujours (c’est le draftid). C’est votre callback qui décide comment interpréter $args — d’où l’importance de suivre la même convention à la génération (make_pluginfile_url) et au décodage.

3.2 La callback _pluginfile : exemple complet ligne par ligne

Chaque plugin qui possède des zones de fichiers doit définir dans son lib.php une fonction {frankenstyle}_pluginfile(). Voici un exemple complet pour un plugin local fictif local_bibliotheque qui stocke des documents dans une zone documents au contexte système :

<?php // Fichier : local/bibliotheque/lib.php /** * Sert les fichiers du plugin local_bibliotheque. * * Appelée par pluginfile.php quand l'URL commence par * /pluginfile.php/{contextid}/local_bibliotheque/... * * @param stdClass $course Le cours, si le contexte est dans un cours (sinon null). * @param stdClass $cm Le course_module, si contexte de module (sinon null). * @param context $context Le contexte reconstruit depuis le contextid de l'URL. * @param string $filearea La zone demandée (segment de l'URL). * @param array $args Les segments RESTANTS de l'URL : [itemid?, ...dossiers, filename]. * @param bool $forcedownload true si ?forcedownload=1 (Content-Disposition: attachment). * @param array $options Options de service (cache, etc.). * @return bool false si non trouvé/non autorisé (la fonction ne "retourne" jamais en cas de succès : * send_stored_file() termine le script). */ function local_bibliotheque_pluginfile($course, $cm, $context, $filearea, $args, $forcedownload, array $options = []) { // 1) Vérifier le NIVEAU de contexte attendu. Notre plugin ne stocke // qu'au contexte système : toute autre valeur est suspecte → refus. if ($context->contextlevel !== CONTEXT_SYSTEM) { return false; // pluginfile.php enverra un 404 propre. } // 2) Vérifier la zone. Un attaquant peut mettre n'importe quoi dans l'URL : // n'acceptez QUE les zones que vous connaissez (liste blanche). if ($filearea !== 'documents') { return false; } // 3) Authentification. require_login() sans cours pour un plugin système ; // pour du contenu public assumé, on pourrait l'omettre — mais c'est un // choix de sécurité à faire consciemment, jamais un oubli. require_login(); // 4) Autorisation métier : la capability qui protège cette zone. // C'est ICI que se joue tout le contrôle d'accès aux fichiers. if (!has_capability('local/bibliotheque:consulter', $context)) { return false; } // 5) Reconstruire le sextuplé depuis $args. // Convention de CE plugin : l'itemid est le premier segment de l'URL. // $args pour ".../documents/42/dossierA/rapport.pdf" vaut : // ['42', 'dossierA', 'rapport.pdf'] $itemid = array_shift($args); // '42' $filename = array_pop($args); // 'rapport.pdf' (dernier segment) if (empty($args)) { $filepath = '/'; // pas de sous-dossier } else { $filepath = '/' . implode('/', $args) . '/'; // '/dossierA/' } // 6) Charger le fichier via la File API. $fs = get_file_storage(); $file = $fs->get_file($context->id, 'local_bibliotheque', $filearea, $itemid, $filepath, $filename); if (!$file || $file->is_directory()) { return false; // introuvable, ou quelqu'un demande un "répertoire". } // 7) Envoyer le fichier. send_stored_file() gère les en-têtes HTTP, // les requêtes Range (reprise de téléchargement, streaming vidéo), // le cache navigateur, X-Sendfile si configuré... puis appelle die(). // Paramètres : fichier, durée de cache (s), filter, forcedownload, options. send_stored_file($file, 86400, 0, $forcedownload, $options); // On n'arrive jamais ici. }

Pour un module d’activité, le squelette diffère légèrement : le contexte attendu est CONTEXT_MODULE, $cm est fourni, et on vérifie l’accès au cours et à l’activité :

<?php // Fichier : mod/monmodule/lib.php function monmodule_pluginfile($course, $cm, $context, $filearea, $args, $forcedownload, array $options = []) { if ($context->contextlevel !== CONTEXT_MODULE) { return false; } // require_login avec cours + cm : vérifie inscription au cours, // visibilité de l'activité, restrictions d'accès (availability)... require_login($course, true, $cm); if ($filearea === 'content') { require_capability('mod/monmodule:view', $context); // Convention "intro-like" pour cette zone : PAS d'itemid dans l'URL, // il vaut toujours 0 en base. $filename = array_pop($args); $filepath = empty($args) ? '/' : '/' . implode('/', $args) . '/'; $fs = get_file_storage(); $file = $fs->get_file($context->id, 'mod_monmodule', 'content', 0, $filepath, $filename); if (!$file || $file->is_directory()) { send_file_not_found(); // envoie un 404 et die() — équivalent à return false, // mais utilisable au milieu d'une logique complexe. } send_stored_file($file, 86400, 0, $forcedownload, $options); } return false; // zone inconnue. }

Notez que la zone intro (la description standard des modules) est servie automatiquement par le cœur pour les modules — vous n’avez à gérer que vos zones spécifiques.

💡 Pour un dev React : pluginfile.php joue le rôle d’un route handler Next.js /api/files/[...path] devant un bucket privé : parsing du catch-all, getServerSession(), contrôle d’autorisation, puis streaming du blob avec les bons headers. La différence culturelle : dans Moodle, ce handler est décentralisé — chaque plugin fournit sa propre logique d’autorisation via la callback, le cœur ne fait que router.

📚 Aller plus loin : la page « File serving » de moodledev.io détaille les variantes (send_file, readfile_accel, gestion des en-têtes de cache, X-Sendfile/X-Accel-Redirect pour déléguer le streaming au serveur web).


4. Draft areas : le cycle de vie des fichiers dans les formulaires

4.1 Le problème que les brouillons résolvent

Imaginez un formulaire d’édition avec un champ « fichiers joints ». Sans mécanisme particulier, deux problèmes :

  1. Atomicité : si l’utilisateur ajoute trois fichiers puis clique « Annuler », ou ferme l’onglet, il ne faut PAS que la zone réelle ait été modifiée. Les uploads doivent rester « en suspens » jusqu’à la validation du formulaire.
  2. Fichiers orphelins : les uploads abandonnés doivent pouvoir être nettoyés automatiquement.

La solution de Moodle : chaque session d’édition travaille sur une copie de travail dans une zone spéciale, le draft areacomponent = 'user', filearea = 'draft', dans le contexte utilisateur de l’éditeur, avec un itemid aléatoire (le draftid) qui identifie cette session d’édition précise. Les brouillons non réclamés sont purgés automatiquement au bout de quelques jours par une tâche planifiée.

Le cycle complet :

ZONE RÉELLE DRAFT AREA (user/draft/{draftid}) mod_monmodule/attachments/0 contexte utilisateur de l'éditeur │ │ │ ① file_prepare_draft_area() │ ├─────────── copie ───────────────────►│ │ │ ② l'utilisateur ajoute/supprime │ │ des fichiers via le filemanager │ │ (AJAX, repository upload...) │ ③ file_save_draft_area_files() │ │◄────────── synchronise ──────────────┤ │ (ajouts copiés, suppressions │ │ répercutées, URLs réécrites) │ ④ le draft devient orphelin, │ │ purgé plus tard par cron

💡 Pour un dev React : le draft area est l’équivalent d’un état local optimiste (useState initialisé depuis le serveur) qu’on ne « commit » vers la source de vérité qu’au onSubmit. Le draftid joue le rôle d’une clé de mutation isolée : deux onglets ouverts sur le même formulaire ont deux draftids différents et ne se marchent pas dessus.

4.2 Round-trip complet dans une page de formulaire

Reprenons le trio de fonctions dans un exemple complet. Contexte : un plugin local_bibliotheque avec une page d’édition permettant d’attacher des fichiers à un « document » (enregistrement en base avec un id).

Le formulaire (classes/form/document_form.php) :

<?php namespace local_bibliotheque\form; use moodleform; class document_form extends moodleform { protected function definition() { $mform = $this->_form; $mform->addElement('text', 'titre', get_string('titre', 'local_bibliotheque')); $mform->setType('titre', PARAM_TEXT); // L'élément filemanager. Sa "valeur" est un draftid (un int), // pas les fichiers eux-mêmes ! $mform->addElement('filemanager', 'attachments', get_string('fichiers', 'local_bibliotheque'), null, [ 'subdirs' => 0, // pas de sous-dossiers 'maxbytes' => 10485760, // 10 Mo par fichier 'maxfiles' => 5, // 5 fichiers max 'accepted_types' => ['.pdf', '.docx'], // liste blanche d'extensions ]); $mform->addElement('hidden', 'id'); $mform->setType('id', PARAM_INT); $this->add_action_buttons(); } }

La page (edit.php) — c’est ici que le cycle draft se joue :

<?php require(__DIR__ . '/../../config.php'); $id = required_param('id', PARAM_INT); $context = context_system::instance(); require_login(); require_capability('local/bibliotheque:gerer', $context); $PAGE->set_url(new moodle_url('/local/bibliotheque/edit.php', ['id' => $id])); $PAGE->set_context($context); $document = $DB->get_record('local_bibliotheque_docs', ['id' => $id], '*', MUST_EXIST); // Les options du filemanager — DOIVENT être identiques dans le formulaire, // dans prepare_draft_area et dans save_draft_area_files. Factoriser ! $fileoptions = [ 'subdirs' => 0, 'maxbytes' => 10485760, 'maxfiles' => 5, 'accepted_types' => ['.pdf', '.docx'], ]; // ─────────────────────────────────────────────────────────────────── // PHASE 1 — Préparer le brouillon (avant d'afficher le formulaire). // ─────────────────────────────────────────────────────────────────── // Récupère le draftid déjà associé à l'élément 'attachments' si le // formulaire a déjà été soumis (erreur de validation → réaffichage) ; // sinon renvoie 0 et prepare_draft_area en générera un nouveau. // CRUCIAL pour ne pas perdre les uploads en cas d'erreur de validation. $draftitemid = file_get_submitted_draft_itemid('attachments'); // Copie les fichiers de la ZONE RÉELLE vers le DRAFT de l'utilisateur. // Signature : (&$draftitemid, $contextid, $component, $filearea, $itemid, $options) // $draftitemid est passé PAR RÉFÉRENCE : s'il vaut 0, un nouveau draftid // aléatoire est généré et écrit dedans. file_prepare_draft_area( $draftitemid, // in/out : le draftid $context->id, // d'où viennent les fichiers réels 'local_bibliotheque', 'attachments', $document->id, // itemid = id du document $fileoptions ); // Injecter le draftid comme valeur par défaut de l'élément filemanager. $document->attachments = $draftitemid; $mform = new \local_bibliotheque\form\document_form(); $mform->set_data($document); // ─────────────────────────────────────────────────────────────────── // PHASE 2 — Traiter la soumission. // ─────────────────────────────────────────────────────────────────── if ($mform->is_cancelled()) { // Rien à nettoyer : le draft sera purgé automatiquement par cron. redirect(new moodle_url('/local/bibliotheque/index.php')); } else if ($data = $mform->get_data()) { $DB->update_record('local_bibliotheque_docs', (object) [ 'id' => $data->id, 'titre' => $data->titre, 'timemodified' => time(), ]); // Synchroniser le DRAFT vers la ZONE RÉELLE : // - les fichiers ajoutés dans le draft sont copiés dans la zone réelle ; // - les fichiers supprimés du draft sont supprimés de la zone réelle ; // - les fichiers inchangés sont conservés (aucun blob dupliqué). // Les mêmes $fileoptions font respecter maxfiles/subdirs/accepted_types // CÔTÉ SERVEUR (jamais confiance au client !). file_save_draft_area_files( $data->attachments, // le draftid soumis par le formulaire $context->id, 'local_bibliotheque', 'attachments', $data->id, // itemid de la zone réelle $fileoptions ); redirect(new moodle_url('/local/bibliotheque/index.php'), get_string('changessaved'), null, \core\output\notification::NOTIFY_SUCCESS); } echo $OUTPUT->header(); $mform->display(); echo $OUTPUT->footer();

Pour les champs editor (texte riche + fichiers embarqués) et pour combiner texte et zone de fichiers, on utilise les helpers de plus haut niveau file_prepare_standard_editor() / file_postupdate_standard_editor() et file_prepare_standard_filemanager() / file_postupdate_standard_filemanager(), présentés au chapitre 03 sur les formulaires — ils enveloppent exactement les fonctions ci-dessus.

⚠️ Piège : les options (maxfiles, subdirs, accepted_types, maxbytes) doivent être identiques aux trois endroits — définition de l’élément, file_prepare_draft_area, file_save_draft_area_files. En cas d’incohérence, symptômes vicieux : fichiers silencieusement écartés à la sauvegarde, ou sous-dossiers perdus. Définissez-les une fois dans une constante ou une méthode statique du plugin.

⚠️ Piège : n’oubliez pas file_get_submitted_draft_itemid() avant file_prepare_draft_area(). Sans lui, chaque réaffichage du formulaire (erreur de validation) régénère un draft vierge depuis la zone réelle, et l’utilisateur perd les fichiers qu’il venait d’ajouter. Bug classique et rageant.


5. file_rewrite_pluginfile_urls : le placeholder @@PLUGINFILE@@

5.1 Le problème : des URLs stables en base

Quand un enseignant insère une image dans une description via l’éditeur, quel src stocker en base ? Une URL absolue (https://monsite/pluginfile.php/950/...) casserait à la migration de domaine, au restore d’une sauvegarde dans un autre cours (nouveaux contextids !), ou au passage http→https.

Solution : en base de données, les URLs de fichiers embarqués sont stockées sous forme de placeholder :

<!-- Ce qui est stocké dans la colonne intro de mdl_assign : --> <p>Étudiez le schéma :</p> <img src="@@PLUGINFILE@@/schema.png" alt="Architecture"> <img src="@@PLUGINFILE@@/images/detail.png" alt="Détail">

Le placeholder est relatif à la zone de fichiers associée au champ. À l’affichage, on le réécrit en URL réelle avant de passer le texte à format_text() :

<?php // Affichage de la description d'une instance de module avec images embarquées. $context = context_module::instance($cm->id); // 1) Réécrire @@PLUGINFILE@@ en vraies URLs pluginfile.php. // Signature : ($text, $file, $contextid, $component, $filearea, $itemid, $options) // Le 2e paramètre est le nom du script de service ('pluginfile.php' dans // 99 % des cas ; 'draftfile.php' pour prévisualiser un brouillon). $intro = file_rewrite_pluginfile_urls( $instance->intro, // le texte brut venant de la base 'pluginfile.php', $context->id, 'mod_monmodule', 'intro', null // itemid : null → PAS d'itemid dans l'URL (convention "intro") ; // passez un int pour l'inclure (convention avec itemid). ); // 2) PUIS formater (filtres, nettoyage XSS...) — cf. chapitre 04. echo format_text($intro, $instance->introformat, ['context' => $context]); // Résultat rendu : // <img src="https://monsite/pluginfile.php/950/mod_monmodule/intro/schema.png" ...>

Le sens inverse (URL → placeholder) est automatique : quand file_save_draft_area_files() est appelé avec un texte (via file_postupdate_standard_editor() ou son paramètre $text), les URLs draftfile.php/... insérées par l’éditeur pendant l’édition sont retransformées en @@PLUGINFILE@@/... avant stockage. Vous n’écrivez jamais le placeholder à la main.

💡 Pour un dev React : c’est le même principe que stocker des chemins relatifs dans un CMS headless et les résoudre au rendu avec le domaine du CDN du moment — ou que le basePath/assetPrefix de Next.js : la base est résolue tard, au rendu, jamais figée dans le contenu.

5.2 Générer une URL de fichier en PHP : moodle_url::make_pluginfile_url()

Pour construire une URL de téléchargement hors contexte d’un texte riche (lien de téléchargement dans une liste, src d’une image générée en PHP) :

<?php $fs = get_file_storage(); $files = $fs->get_area_files($context->id, 'local_bibliotheque', 'attachments', $document->id, 'filename', false); foreach ($files as $file) { // Signature : make_pluginfile_url($contextid, $component, $area, // $itemid, $pathname, $filename, // $forcedownload = false) $url = moodle_url::make_pluginfile_url( $file->get_contextid(), $file->get_component(), $file->get_filearea(), $file->get_itemid(), // passez null si votre callback n'attend pas d'itemid ! $file->get_filepath(), $file->get_filename(), true // forcedownload : Content-Disposition attachment ); echo html_writer::link($url, s($file->get_filename())); }

⚠️ Piège : la convention itemid de make_pluginfile_url() doit refléter exactement ce que votre callback _pluginfile décode. Si vous générez l’URL avec itemid = 0 mais que la callback fait array_shift($args) en croyant lire un itemid, elle consommera le premier dossier ou le nom du fichier → 404 mystérieux. Testez toujours le couple génération/décodage ensemble.


PARTIE B — LA STRING API

6. get_string et les fichiers de langue

6.1 Principe : aucun texte en dur, jamais

Moodle est traduit dans plus d’une centaine de langues. Tout texte visible par l’utilisateur passe par la String API :

<?php // get_string('identifiant', 'composant', $donnees_optionnelles) echo get_string('pluginname', 'local_bibliotheque'); // "Bibliothèque documentaire" echo get_string('save'); // composant omis = 'core' (alias 'moodle') echo get_string('modulename', 'mod_quiz'); echo get_string('privacy:metadata', 'local_bibliotheque');

Le composant est en frankenstyle : mod_quiz, block_html, local_bibliotheque, tool_uploaduser… Le composant 'moodle' (ou 'core', ou omis) désigne les chaînes du noyau (lang/en/moodle.php). Les sous-systèmes du cœur ont leurs propres composants (core_calendar, core_files…).

Chaque chaîne vit dans un fichier PHP par langue et par composant. L’anglais est obligatoire et fait foi : c’est la source de vérité, les autres langues n’en sont que des traductions. Un plugin qui ne shippe que du français est cassé pour tout le reste du monde et pour le système de traduction.

<?php // Fichier : local/bibliotheque/lang/en/local_bibliotheque.php // Le nom du fichier = frankenstyle du plugin, OBLIGATOIREMENT. defined('MOODLE_INTERNAL') || die(); // 'pluginname' est OBLIGATOIRE pour tout plugin : c'est le nom affiché // dans l'administration, la liste des plugins, etc. $string['pluginname'] = 'Document library'; $string['titre'] = 'Title'; $string['fichiers'] = 'Attached files'; // Convention : 'xxx_help' est le texte du bouton d'aide (?) associé à // l'élément de formulaire dont le label est la chaîne 'xxx'. // $mform->addHelpButton('titre', 'titre', 'local_bibliotheque'); $string['titre_help'] = 'The title is displayed in the library index and in search results.'; // Convention : les chaînes 'privacy:metadata...' décrivent les données // personnelles stockées (Privacy API / RGPD) — contrôlées par les tests. $string['privacy:metadata'] = 'The Document library plugin does not store any personal data.'; // Convention : les noms de capabilities → 'nomplugin:capability'. $string['bibliotheque:gerer'] = 'Manage library documents'; // Convention : les noms de tâches planifiées, d'événements, de settings... $string['apikey'] = 'API key'; $string['apikey_desc'] = 'Key used to authenticate against the external catalogue service.';

La traduction française vit dans lang/fr/local_bibliotheque.php avec les mêmes clés — mais pour un plugin publié, elle ne vit pas dans le plugin (voir AMOS ci-dessous).

💡 Pour un dev React : get_string('titre', 'local_bibliotheque') est l’équivalent direct de t('titre', { ns: 'bibliotheque' }) en i18next ou useTranslations('Bibliotheque')('titre') en next-intl : clé + namespace, fichiers de messages par locale, fallback sur la langue par défaut (ici l’anglais, toujours). La grosse différence : pas de compilation ICU MessageFormat — le format des placeholders Moodle est bien plus rudimentaire (voir section 7).

6.2 lang_string, get_strings, et ce qu’il ne faut PAS faire

<?php // new lang_string : une chaîne à résolution PAResseuse. La traduction n'est // effectuée qu'à la conversion en string (__toString). Indispensable dans // les endroits exécutés AVANT que la langue de l'utilisateur soit connue, // typiquement settings.php (chargé aussi par CLI/install) : $title = new lang_string('apikey', 'local_bibliotheque'); // get_strings : résoudre plusieurs chaînes d'un coup (micro-optimisation // + lisibilité) — renvoie un stdClass avec une propriété par identifiant : $strs = get_strings(['save', 'cancel', 'delete'], 'core'); echo $strs->save; // ─── À NE PAS FAIRE ─── // ❌ addslashes()/échappement manuel dans les fichiers de langue : les // chaînes sont du PHP normal, on échappe uniquement la syntaxe PHP // (apostrophe dans une chaîne à quotes simples : 'l\'utilisateur'). // Aucun échappement HTML/SQL dans les fichiers lang. // ❌ Concaténer des morceaux de phrases traduites : l'ordre des mots change // selon les langues. Utilisez les placeholders {$a} (section 7). // ❌ Appeler get_string dans une boucle serrée sur la même clé : le string // manager cache déjà, mais sortez l'appel de la boucle par principe.

⚠️ Piège : après avoir ajouté une chaîne dans lang/en/…, elle peut ne pas apparaître immédiatement : les chaînes sont mises en cache (MUC). En développement, purgez les caches (php admin/cli/purge_caches.php ou Administration → Développement → Purger les caches). Le symptôme classique est l’affichage [[identifiant]] — qui signifie « chaîne introuvable » : clé mal orthographiée, mauvais composant, ou cache non purgé.

6.3 AMOS, packs de langue et surcharge locale

Comment le français arrive-t-il sur votre site ? Par le système communautaire AMOS (translate.moodle.org ) :

  • Les traducteurs bénévoles traduisent les chaînes anglaises via AMOS.
  • AMOS génère les packs de langue téléchargeables : fr (français), fr_ca (français canadien, qui hérite de fr et ne surcharge que les différences), et ainsi de suite. Un pack enfant ne contient que ses deltas — mécanisme d’héritage de langues.
  • L’admin installe les packs via Administration → Langue → Packs de langue ; ils sont déposés dans moodledata/lang/fr/, moodledata/lang/fr_ca/

L’ordre de résolution d’une chaîne pour un utilisateur en fr_ca est donc : surcharge locale fr_ca → pack fr_ca → surcharge locale fr → pack fr → anglais du code (lang/en/ du plugin ou du cœur).

La surcharge locale (language customisation) permet à l’admin de modifier n’importe quelle chaîne sans toucher au code : Administration → Langue → Personnalisation de la langue. Les surcharges sont stockées dans moodledata/lang/fr_local/ et survivent aux mises à jour des packs. Cas d’usage typique : renommer « Cours » en « Formation » sur tout le site.

Workflow pour un plugin tiers : vous shippez uniquement lang/en/ dans le code du plugin. Une fois le plugin publié dans le répertoire officiel (moodle.org/plugins), ses chaînes anglaises sont importées dans AMOS, la communauté les traduit, et les traductions sont distribuées via les packs de langue standards — pas via votre dépôt. Pour un plugin interne non publié, vous pouvez en revanche inclure lang/fr/ directement dans le plugin : c’est supporté et pratique.

6.4 Les chaînes côté JavaScript et Mustache (renvois)

Les fichiers de langue PHP servent aussi le front. Deux mécanismes, détaillés aux chapitres AMD/Templates :

// En JavaScript (module AMD/ESM) — asynchrone, car chargé via AJAX + cache : import {getString} from 'core/str'; const label = await getString('fichiers', 'local_bibliotheque');
{{! Dans un template Mustache — le helper str : }} {{#str}} fichiers, local_bibliotheque {{/str}}

7. Placeholders {$a}, échappement et pluriels

7.1 Le placeholder {$a} : scalaire ou structure

Le troisième argument de get_string() est injecté dans la chaîne à l’emplacement des placeholders {$a} :

<?php // ── Cas scalaire : {$a} tout court ── // lang/en : $string['docscount'] = 'The library contains {$a} documents.'; echo get_string('docscount', 'local_bibliotheque', 42); // → "The library contains 42 documents." // ── Cas structuré : {$a->propriete} — objet OU tableau associatif ── // lang/en : $string['uploadedby'] = '{$a->filename} uploaded by {$a->author} on {$a->date}'; $a = (object) [ 'filename' => $file->get_filename(), 'author' => fullname($uploader), 'date' => userdate($file->get_timecreated()), ]; echo get_string('uploadedby', 'local_bibliotheque', $a); // → "rapport.pdf uploaded by Ada Lovelace on 8 July 2026, 10:15 AM" // Un tableau associatif marche aussi : echo get_string('uploadedby', 'local_bibliotheque', ['filename' => 'x.pdf', 'author' => 'Ada', 'date' => 'today']);

C’est le mécanisme qui rend la concaténation inutile et nuisible : la position de {$a->author} dans la phrase française peut différer de l’anglaise, et seul un placeholder le permet.

7.2 Échappement : les chaînes ne sont PAS du HTML sûr

Point de sécurité crucial : get_string() n’échappe rien. La chaîne du fichier de langue est renvoyée telle quelle, placeholders substitués sans encodage. Deux conséquences :

  1. Une chaîne de langue peut contenir du HTML léger (<strong>, liens) — c’est accepté par convention pour certaines chaînes. Le fichier de langue est considéré comme du contenu de confiance (c’est du code).
  2. Mais les données injectées via {$a}, elles, viennent souvent de l’utilisateur — et là, XSS en vue :
<?php // ❌ DANGEREUX : $record->titre vient d'un utilisateur. S'il contient // "<script>...", il sera rendu tel quel dans la page. echo get_string('deleteconfirm', 'local_bibliotheque', $record->titre); // ✅ CORRECT : nettoyer la donnée AVANT de l'injecter. // format_string() pour un contenu "une ligne" saisi par un utilisateur // (applique aussi les filtres, ex. multilangue) : echo get_string('deleteconfirm', 'local_bibliotheque', format_string($record->titre, true, ['context' => $context])); // Ou s() (htmlspecialchars) pour un échappement brut sans filtres : echo get_string('deleteconfirm', 'local_bibliotheque', s($record->titre));

La règle mnémotechnique : la chaîne de langue est sûre, les données injectées ne le sont pas. Échappez les données au moment de construire $a, en fonction du contexte de sortie (cf. chapitre 04 sur format_string/format_text/s()).

⚠️ Piège : ne passez jamais {$a} dans un attribut HTML construit dans la chaîne de langue (<a href="{$a->url}">) sans réfléchir : si l’URL vient de l’utilisateur, c’est une injection d’attribut. Préférez construire le lien en PHP avec html_writer::link() et injecter le lien complet comme donnée déjà sûre.

7.3 Pluriels : le trou dans la raquette

Mauvaise nouvelle pour qui vient d’ICU MessageFormat : Moodle n’a pas de vrai système de pluriel. Pas de {count, plural, one {...} other {...}}. Les workarounds établis :

<?php // Workaround 1 (le standard Moodle) : deux chaînes distinctes + un if. // lang/en : // $string['numdoc'] = '{$a} document'; // $string['numdocs'] = '{$a} documents'; $count = count($documents); echo ($count == 1) ? get_string('numdoc', 'local_bibliotheque', $count) : get_string('numdocs', 'local_bibliotheque', $count); // Workaround 2 : formulation neutre qui évite le problème. // $string['doccount'] = 'Documents: {$a}'; → "Documents : 42" // Workaround 3 (dates/durées) : le cœur fournit format_time(), get_string // avec des unités séparées ('numdays', 'numhours'...), etc. Réutilisez-les.

C’est imparfait (le russe a trois formes de pluriel, l’arabe six…), mais c’est l’état de l’art Moodle : les traducteurs AMOS composent avec. Retenez : jamais de 'document' . ($count > 1 ? 's' : '') — le « s » anglais n’est pas universel.

📚 Aller plus loin : moodledev.io/docs/apis/subsystems/string  pour la String API, et la documentation AMOS sur translate.moodle.org pour le workflow de traduction complet (dont la soumission de traductions pour votre propre plugin).


PARTIE C — CONFIGURATION

8. get_config / set_config : lire et écrire des réglages

8.1 L’API

Tout réglage persistant — clé d’API, seuil, activation d’une fonctionnalité — passe par l’API de configuration, jamais par une table maison ni un fichier :

<?php // ── Lire ── // Toute la config d'un plugin d'un coup : renvoie un stdClass // (propriétés = noms de réglages). Propriété absente si jamais définie ! $config = get_config('local_bibliotheque'); if (!empty($config->apikey)) { $client = new api_client($config->apikey, $config->endpoint); } // Un seul réglage : renvoie la valeur, ou FALSE si non défini. $apikey = get_config('local_bibliotheque', 'apikey'); if ($apikey === false) { // Réglage jamais défini — distinct de '' (défini mais vide) ! throw new moodle_exception('apikeynotset', 'local_bibliotheque'); } // ── Écrire ── // set_config($name, $value, $plugin) — $value est TOUJOURS stocké en texte. set_config('apikey', 'sk-abc123', 'local_bibliotheque'); set_config('maxdocs', 500, 'local_bibliotheque'); // relu comme la CHAÎNE '500' set_config('enabled', 1, 'local_bibliotheque'); // les booléens → '1'/'0' // ── Supprimer ── unset_config('apikey', 'local_bibliotheque'); // la clé disparaît (≠ mettre '')

⚠️ Piège : tout est stocké en texte. set_config('enabled', false, ...) stocke la chaîne vide '', et get_config vous rendra '0', '1', '' ou false (non défini) — quatre valeurs faciles à confondre en comparaison lâche. Écrivez explicitement 0/1, relisez avec (bool)/(int), et testez « non défini » avec === false. De même, ne stockez pas d’objets : sérialisez explicitement (json_encode) si nécessaire, et gardez ça rare.

8.2 Où c’est stocké, et le cache

Deux tables :

  • config : les réglages du cœur ($CFG). Colonnes name, value.
  • config_plugins : les réglages des plugins. Colonnes plugin, name, value.

L’intégralité est chargée en cache (MUC, cache d’application partagé entre requêtes) : get_config() ne coûte pratiquement rien à l’exécution, n’ayez aucun scrupule à l’appeler où vous en avez besoin plutôt que de trimballer la valeur en paramètre sur six couches. set_config() invalide le cache automatiquement.

8.3 $CFG vs get_config : la règle

<?php global $CFG; // Réglages CŒUR : propriétés de l'objet global $CFG, chargé depuis la // table config au bootstrap (+ config.php par-dessus). echo $CFG->wwwroot; // URL racine du site (vient de config.php) echo $CFG->dataroot; // chemin moodledata (config.php) echo $CFG->maxbytes; // taille max d'upload (table config, modifiable en admin) echo $CFG->theme; // thème du site // Équivalence : get_config('core', 'theme') === $CFG->theme. // ('core' et 'moodle' sont synonymes ici.) // Réglages PLUGIN : JAMAIS sur $CFG → toujours get_config('frankenstyle'). $maxdocs = get_config('local_bibliotheque', 'maxdocs');

La règle : les réglages du cœur se lisent sur $CFG (c’est idiomatique et c’est ce que fait tout le code du noyau) ; les réglages de plugin se lisent via get_config('nom_plugin', ...). Historiquement certains vieux plugins stockaient leurs réglages dans la table config avec un préfixe ($CFG->monplugin_machin) — c’est un anti-pattern à ne pas reproduire.

💡 Pour un dev React : $CFG + get_config = vos variables d’environnement + feature flags, avec une hiérarchie familière : config.php joue le rôle du fichier .env (valeurs d’infrastructure, prioritaires, non modifiables depuis l’UI), la table config/config_plugins celui d’un service de configuration dynamique type LaunchDarkly/Unleash (modifiable à chaud depuis l’admin, caché agressivement). Et comme pour les env vars : une valeur forcée dans config.php « gagne » toujours sur la base.

8.4 Forcer des réglages dans config.php

Pour verrouiller des réglages au niveau infrastructure (déploiement multi-environnements, IaC), on les force dans config.php :

<?php // config.php — exécuté à CHAQUE requête, avant tout. // Réglage cœur forcé : simple propriété de $CFG. La valeur en base est // ignorée et l'UI d'admin affiche le réglage grisé "Défini dans config.php". $CFG->theme = 'boost'; $CFG->debug = E_ALL; // env de dev $CFG->debugdisplay = 1; // Réglages de PLUGIN forcés : via le tableau forced_plugin_settings, // indexé par frankenstyle puis par nom de réglage. $CFG->forced_plugin_settings = [ 'local_bibliotheque' => [ 'apikey' => getenv('BIBLIO_API_KEY'), // tirer de l'environnement ! 'endpoint' => 'https://catalogue.example.com/api', ], 'tool_mobile' => [ 'enablesmartappbanners' => 0, ], ];

get_config() respecte automatiquement ces valeurs forcées : elles priment sur la table config_plugins, et set_config() sur un réglage forcé n’a pas d’effet visible à la lecture.


9. Pages de réglages admin : settings.php

9.1 Le mécanisme

Dernier maillon : offrir une interface d’administration pour ces réglages. Il suffit de créer un fichier settings.php à la racine du plugin. Pour la plupart des types de plugins (local, mod, block…), Moodle fournit une variable $settings — une instance d’admin_settingpage déjà créée, nommée et raccrochée au bon endroit de l’arborescence d’administration. Votre travail : y ajouter des admin_setting_*.

Point clé : chaque admin_setting déclare son nom sous la forme 'frankenstyle/nomreglage' — c’est ce / qui dit à Moodle de stocker dans config_plugins avec plugin = 'frankenstyle'. À l’exécution, la boucle est bouclée : get_config('local_bibliotheque', 'apikey') lit exactement ce que la page a écrit.

9.2 Exemple complet et commenté

<?php // Fichier : local/bibliotheque/settings.php // Chargé quand l'arbre d'admin est construit (pages d'admin, upgrade...). defined('MOODLE_INTERNAL') || die(); // $hassiteconfig est fourni par le chargeur : true si l'utilisateur courant // a la capability moodle/site:config. Quand l'arbre est construit pour un // utilisateur SANS ce droit, $settings vaut NULL → toujours garder ce if, // sinon fatal error "Call to a member function add() on null". if ($hassiteconfig) { // $settings est une admin_settingpage déjà créée par le cœur pour les // plugins 'local'. (Pour certains types, $settings est null et il faut // créer la page soi-même — voir 9.3.) // ── Un en-tête de section (titre + paragraphe, aucun réglage stocké) ── $settings->add(new admin_setting_heading( 'local_bibliotheque/apiheading', // nom unique get_string('apiheading', 'local_bibliotheque'), // titre get_string('apiheading_desc', 'local_bibliotheque') // description )); // ── Champ texte ── // Args : nom 'plugin/reglage', libellé, description, valeur par défaut, type PARAM. $settings->add(new admin_setting_configtext( 'local_bibliotheque/endpoint', get_string('endpoint', 'local_bibliotheque'), get_string('endpoint_desc', 'local_bibliotheque'), 'https://catalogue.example.com/api', // défaut PARAM_URL // validation/nettoyage à la sauvegarde )); // ── Mot de passe / secret : masqué à l'écran, bouton "révéler" ── $settings->add(new admin_setting_configpasswordunmask( 'local_bibliotheque/apikey', get_string('apikey', 'local_bibliotheque'), get_string('apikey_desc', 'local_bibliotheque'), '' // pas de défaut pour un secret )); // ── Case à cocher : valeurs stockées pour coché/décoché ('1'/'0') ── $settings->add(new admin_setting_configcheckbox( 'local_bibliotheque/enabled', get_string('enabled', 'local_bibliotheque'), get_string('enabled_desc', 'local_bibliotheque'), 1, // défaut : activé '1', '0' // valeurs stockées (défauts, souvent omis) )); // ── Liste déroulante : tableau valeur => libellé ── $settings->add(new admin_setting_configselect( 'local_bibliotheque/sortmode', get_string('sortmode', 'local_bibliotheque'), get_string('sortmode_desc', 'local_bibliotheque'), 'title', // défaut [ 'title' => get_string('sortbytitle', 'local_bibliotheque'), 'date' => get_string('sortbydate', 'local_bibliotheque'), ] )); // ── Zone de texte multi-lignes (brut) ── $settings->add(new admin_setting_configtextarea( 'local_bibliotheque/allowedhosts', get_string('allowedhosts', 'local_bibliotheque'), get_string('allowedhosts_desc', 'local_bibliotheque'), '' )); // ── Éditeur HTML (contenu riche, ex. message d'accueil) ── $settings->add(new admin_setting_confightmleditor( 'local_bibliotheque/welcomemessage', get_string('welcomemessage', 'local_bibliotheque'), get_string('welcomemessage_desc', 'local_bibliotheque'), '' )); // ── Durée : rendue avec nombre + unité, stockée en SECONDES ── $settings->add(new admin_setting_configduration( 'local_bibliotheque/cachettl', get_string('cachettl', 'local_bibliotheque'), get_string('cachettl_desc', 'local_bibliotheque'), 3600, // défaut : 1 h 3600 // unité affichée par défaut : heures )); // ── Sélecteur de couleur ── $settings->add(new admin_setting_configcolourpicker( 'local_bibliotheque/accentcolour', get_string('accentcolour', 'local_bibliotheque'), get_string('accentcolour_desc', 'local_bibliotheque'), '#0f6cbf' )); // ── Fichier stocké : dépose un fichier dans une zone du contexte système // (component = frankenstyle du plugin, filearea/itemid = args). // Boucle avec la Partie A : on le relit avec get_area_files() ! ── $settings->add(new admin_setting_configstoredfile( 'local_bibliotheque/logo', get_string('logo', 'local_bibliotheque'), get_string('logo_desc', 'local_bibliotheque'), 'logo', // filearea 0, // itemid ['maxfiles' => 1, 'accepted_types' => ['web_image']] )); }

Chaque libellé référencé (endpoint, endpoint_desc…) doit exister dans lang/en/local_bibliotheque.php. Une fois le plugin installé, la page apparaît sous Administration du site → Plugins → Plugins locaux → Bibliothèque documentaire, et chaque sauvegarde écrit dans config_plugins.

⚠️ Piège : settings.php est inclus dans des contextes variés (construction de l’arbre d’admin, upgrade, recherche admin), y compris avant que la session utilisateur soit complète. N’y mettez aucune logique lourde, pas d’accès DB inutile, et utilisez new lang_string(...) plutôt que get_string(...) si vous rencontrez des soucis d’initialisation de langue (les classes admin_setting_* acceptent les deux). Et gardez le if ($hassiteconfig) ou if ($ADMIN->fulltree) selon les recettes officielles — hors de ces gardes, $settings peut être null.

9.3 Créer sa propre page ou catégorie : $ADMIN->add()

Quand la page auto-fournie ne suffit pas (plusieurs pages, une page « outil » non déclarative, un regroupement), on manipule l’arbre d’admin directement via $ADMIN :

<?php // settings.php d'un plugin plus ambitieux. defined('MOODLE_INTERNAL') || die(); if ($hassiteconfig) { // 1) Une CATÉGORIE à nous dans l'arborescence. // add(parent, noeud) : 'localplugins' est la catégorie des plugins locaux. $ADMIN->add('localplugins', new admin_category( 'local_bibliotheque_cat', // nom interne unique new lang_string('pluginname', 'local_bibliotheque') // libellé (lazy !) )); // 2) Une page de réglages déclarative DANS notre catégorie. $page = new admin_settingpage( 'local_bibliotheque_general', new lang_string('generalsettings', 'local_bibliotheque') ); $page->add(new admin_setting_configtext( 'local_bibliotheque/endpoint', new lang_string('endpoint', 'local_bibliotheque'), new lang_string('endpoint_desc', 'local_bibliotheque'), '', PARAM_URL )); $ADMIN->add('local_bibliotheque_cat', $page); // 3) Une page ENTIÈREMENT CUSTOM (votre propre script PHP : tableau de // bord, outil d'import...) : admin_externalpage l'enregistre dans // l'arbre + la recherche admin, et pointe vers votre URL. $ADMIN->add('local_bibliotheque_cat', new admin_externalpage( 'local_bibliotheque_import', // nom interne new lang_string('importtool', 'local_bibliotheque'), new moodle_url('/local/bibliotheque/import.php'), // votre page 'local/bibliotheque:gerer' // capability requise )); // Quand on gère l'arbre soi-même, on neutralise la page auto-fournie // pour éviter un doublon vide dans le menu : $settings = null; }

Et dans la page custom import.php, le pendant obligatoire :

<?php require(__DIR__ . '/../../config.php'); require_once($CFG->libdir . '/adminlib.php'); // admin_externalpage_setup fait tout le rituel : require_login, vérification // de la capability déclarée, $PAGE->set_context/set_url, fil d'Ariane admin... admin_externalpage_setup('local_bibliotheque_import'); // ← le nom interne déclaré echo $OUTPUT->header(); echo $OUTPUT->heading(get_string('importtool', 'local_bibliotheque')); // ... votre outil ... echo $OUTPUT->footer();

Sur le contrôle d’accès : les pages de réglages déclaratives sont protégées implicitement par moodle/site:config (d’où le $hassiteconfig) ; les admin_externalpage prennent une capability explicite en paramètre, ce qui permet de déléguer une page d’outil à des gestionnaires non-admins.

💡 Pour un dev React : settings.php est un formulaire déclaratif généré par schéma — pensez à un zod schema + un form builder qui produit l’UI, la validation (PARAM_URL…) et la persistance automatiquement. Vous ne rendez rien vous-même : vous déclarez des champs typés, le framework fait le CRUD vers config_plugins, et get_config() est votre process.env typé à la lecture.

📚 Aller plus loin : moodledev.io/docs/apis/subsystems/admin  recense toutes les classes admin_setting_* (il y en a des dizaines : configmultiselect, configempty, configdirectory, pickroles…) et les subtilités de l’arbre d’admin ($ADMIN->fulltree, pages cachées, admin_settings_coursecat_select…).


Résumé — points clés

File API

  • Les fichiers vivent en content-addressable storage (moodledata/filedir, chemin dérivé du contenthash SHA-1, dédoublonnage automatique) ; les métadonnées vivent dans la table files. Jamais d’accès disque direct.
  • Un fichier logique est adressé par le sextuplé contextid + component + filearea + itemid + filepath + filename ; le pathnamehash l’indexe. Les répertoires sont des lignes filename = '.'.
  • API : $fs = get_file_storage() ; get_area_files() (penser $includedirs = false), get_file(), create_file_from_string/pathname/storedfile(), delete_area_files() ; sur stored_file : get_content() (petits fichiers), copy_content_to()/handles (gros fichiers), delete().
  • Le service passe par pluginfile.php + la callback {plugin}_pluginfile() dans lib.php : liste blanche de zones, require_login, capability, reconstruction du chemin, send_stored_file(). La présence de l’itemid dans l’URL est une convention par zone — génération (moodle_url::make_pluginfile_url) et décodage doivent être symétriques.
  • Formulaires : cycle draftfile_get_submitted_draft_itemid()file_prepare_draft_area() (réel → brouillon) → édition → file_save_draft_area_files() (brouillon → réel). Options identiques partout.
  • Textes riches : en base, les URLs de fichiers sont le placeholder @@PLUGINFILE@@ ; à l’affichage, file_rewrite_pluginfile_urls() avant format_text() ; le sens inverse est automatique à la sauvegarde du draft.

String API

  • get_string('identifiant', 'frankenstyle') ; chaînes dans lang/en/{frankenstyle}.phpl’anglais est obligatoire et source de vérité. pluginname obligatoire ; conventions xxx_help, privacy:metadata, noms de capabilities.
  • Traductions communautaires via AMOS (translate.moodle.org), packs installés dans moodledata/lang/xx, héritage (fr_cafren), surcharge locale xx_local par l’admin.
  • Placeholders {$a} (scalaire) et {$a->prop} (objet/tableau) — jamais de concaténation. Les chaînes ne sont pas échappées : nettoyer les données injectées (format_string, s()) contre le XSS. Pas de vrai système de pluriel : deux chaînes + if, ou formulation neutre.
  • new lang_string() pour la résolution paresseuse (settings.php), get_strings() pour les lots ; côté JS core/str, côté Mustache {{#str}}.

Configuration

  • Cœur : $CFG->xxx (table config) ; plugins : get_config('frankenstyle', 'reglage') / set_config() / unset_config() (table config_plugins). Tout est stocké en texte ; === false = non défini. Cache automatique, lectures gratuites.
  • config.php force les valeurs : propriétés $CFG pour le cœur, $CFG->forced_plugin_settings pour les plugins — priorité absolue sur la base.
  • settings.php : $settings (admin_settingpage fournie) + classes admin_setting_config* nommées 'frankenstyle/reglage' ; garde if ($hassiteconfig) ($settings peut être null). $ADMIN->add() avec admin_category / admin_settingpage / admin_externalpage (+ admin_externalpage_setup() dans la page) pour des structures custom. Ce que la page écrit, get_config() le lit.

Navigation : ← 04 — Output API · 06 — Events, Hooks, Tasks, Messages →