03 — Form API : moodleform
Chapitre précédent : 02 — La base de données · Chapitre suivant : 04 — Output API
La Form API est l’un des piliers de Moodle : tout formulaire que vous voyez dans l’interface — création de cours, réglages d’activité, profil utilisateur, préférences — passe par la classe moodleform. Contrairement à un formulaire HTML écrit à la main, un moodleform vous donne gratuitement la protection CSRF, la validation serveur, le nettoyage des données, l’accessibilité et le rendu Bootstrap. En contrepartie, il impose un cadre strict qu’il faut apprendre à respecter — et parfois à contourner intelligemment.
Mini-sommaire
- Pourquoi une Form API ?
definition(): le cœur déclaratif du formulaire- Tour complet des éléments
addElement() setType(): obligatoire, pas optionnel- Règles, valeurs par défaut, aide et affichage conditionnel
- Validation custom :
validation($data, $files) - Le cycle de traitement complet : le pattern canonique
- Éditeur, filepicker, filemanager : le trio des draft areas
- Repeat elements et formulaires dynamiques (
dynamic_form) - Le markup généré et ses limites
- Résumé — points clés
1. Pourquoi une Form API ?
1.1 Un peu d’histoire : HTML_QuickForm
Au début des années 2000, l’écosystème PHP était structuré autour de PEAR (PHP Extension and Application Repository), l’ancêtre de Composer. L’une de ses bibliothèques les plus populaires était HTML_QuickForm : une API orientée objet pour déclarer des formulaires, leurs règles de validation et leur rendu.
Moodle 1.x utilisait des formulaires HTML écrits à la main, avec toutes les failles que cela implique (XSS, CSRF, validation oubliée…). À partir de Moodle 1.7 (2006), l’équipe a forké HTML_QuickForm et l’a embarqué dans le noyau (lib/pear/HTML/QuickForm/), puis l’a enveloppé dans deux classes maison :
MoodleQuickForm: l’extension du moteur QuickForm, qui ajoute les éléments spécifiques Moodle (éditeur, filepicker, sélecteur de dates…), la gestion dusesskey,disabledIf/hideIf, etc.moodleform: la classe abstraite que vous étendez. Elle orchestre le cycle de vie complet : définition, soumission, validation, récupération des données.
PEAR est mort depuis longtemps, mais le fork embarqué de QuickForm vit toujours dans Moodle 5.2, profondément modernisé (PHP 8.3, rendu Mustache, JS AMD). C’est pourquoi certaines conventions de nommage paraissent datées (addElement, addRule, camelCase mélangé au snake_case Moodle) : c’est l’héritage PEAR.
1.2 Ce que la Form API vous donne gratuitement
- Protection CSRF automatique : chaque formulaire embarque un champ caché
sesskey(jeton de session), vérifié à la soumission. Vous n’avez rien à faire. - Validation serveur systématique :
get_data()ne retourne les données que si le formulaire est valide. Impossible de « rater » la validation. - Nettoyage des données : chaque champ déclare son type (
setType) et les données sont nettoyées viaclean_param()à la soumission. - Accessibilité : labels associés, attributs ARIA, messages d’erreur reliés aux champs, navigation clavier — le tout généré automatiquement.
- Rendu Bootstrap via templates Mustache : depuis Moodle 3.x, chaque élément est rendu par un template
core_form/element-*(situé danslib/form/templates/), stylé par Boost (Bootstrap 5 en Moodle 5.x). Vos formulaires ressemblent nativement au reste de Moodle. - Protection anti double-soumission, gestion des brouillons de fichiers, sections repliables, champs « avancés »…
💡 Pour un dev React :
moodleformest l’équivalent conceptuel de react-hook-form + zod + un composant<Form>maison, le tout côté serveur.definition()joue le rôle du schéma déclaratif (comme unz.object({...})combiné au JSX des champs),setType()correspond aux types zod (z.number(),z.string()),addRule()aux raffinements (z.string().min(1).email()),validation()àsuperRefine()pour la validation croisée, ethideIf()/disabledIf()au rendu conditionnel piloté parwatch(). La grosse différence : tout est rendu et validé côté serveur, le JS n’étant qu’une couche de confort.
1.3 Où mettre ses classes de formulaires
Deux conventions coexistent :
Convention moderne (recommandée) — classes autoloadées dans classes/form/ :
local/foo/
└── classes/
└── form/
└── entry_form.php → classe \local_foo\form\entry_form<?php
// Fichier : local/foo/classes/form/entry_form.php
namespace local_foo\form;
// formslib.php n'est PAS autoloadé : les classes moodleform et
// MoodleQuickForm sont des classes "legacy" définies dans ce fichier.
// Le require_once est donc obligatoire même en 2026.
defined('MOODLE_INTERNAL') || die();
require_once($CFG->libdir . '/formslib.php');
/**
* Formulaire d'édition d'une entrée.
*
* @package local_foo
* @copyright 2026 Votre nom
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class entry_form extends \moodleform {
protected function definition() {
// Voir section 2.
}
}Convention legacy — un fichier edit_form.php (ou mod_form.php pour les modules d’activité) à la racine du plugin, contenant une classe préfixée mais non namespacée (local_foo_edit_form). Vous croiserez ce style partout dans le code du noyau et les vieux plugins. Il fonctionne toujours, mais tout nouveau code doit utiliser classes/form/ avec un namespace : autoloading gratuit, pas de require_once du fichier de classe dans la page appelante, compatibilité avec dynamic_form (section 9).
⚠️ Piège : même dans
classes/form/, lerequire_once($CFG->libdir . '/formslib.php')reste obligatoire en tête de fichier. L’autoloader de Moodle charge votre classeentry_form, mais pas sa classe parente\moodleform, qui n’est pas namespacée et vit dansformslib.php. Sans ce require, vous obtenez une erreur fatale « Class “moodleform” not found ». Notez aussi ledefined('MOODLE_INTERNAL') || die();avant le require : il empêche l’accès direct au fichier par URL (nécessaire ici précisément parce que le fichier contient du code exécutable hors classe — lerequire_once).
📚 Aller plus loin : la page de référence officielle est https://moodledev.io/docs/5.2/apis/subsystems/form — gardez-la ouverte en permanence pendant ce chapitre.
2. definition() : le cœur déclaratif du formulaire
definition() est la seule méthode abstraite de moodleform : vous devez l’implémenter. Elle déclare la structure du formulaire — champs, types, règles, valeurs par défaut. Elle est appelée automatiquement par le constructeur de moodleform.
2.1 $this->_form et $this->_customdata
Deux propriétés protégées sont vos points d’entrée :
$this->_form: l’instance deMoodleQuickForm. Par convention universelle, on l’assigne à une variable locale$mformen première ligne.$this->_customdata: les données arbitraires passées au constructeur du formulaire par la page appelante (tableau ou objet). C’est le seul canal propre pour injecter du contexte dansdefinition(): id du cours, options dynamiques, capacités de l’utilisateur…
💡 Pour un dev React :
$this->_customdata, ce sont les props de votre formulaire. La page appelante faitnew entry_form($url, ['courseid' => $courseid])exactement comme un parent React fait<EntryForm courseId={courseId} />. Ne soyez jamais tenté d’aller lire$_GETdirectement dansdefinition(): ce serait comme lirewindow.locationau fond d’un composant au lieu de recevoir une prop — ça marche, mais ça rend le formulaire intestable et non réutilisable.
2.2 Exemple complet et réaliste
Voici un formulaire d’édition d’« annonce » pour un plugin local_annonces, qui servira de fil rouge dans tout le chapitre :
<?php
namespace local_annonces\form;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->libdir . '/formslib.php');
class annonce_form extends \moodleform {
/**
* Déclaration de la structure du formulaire.
* Appelée automatiquement par le constructeur — ne l'appelez jamais vous-même.
*/
protected function definition() {
$mform = $this->_form;
// Récupération des données injectées par la page appelante.
$courseid = $this->_customdata['courseid'];
$categories = $this->_customdata['categories']; // Tableau id => nom.
$canpin = $this->_customdata['canpin']; // Booléen : capacité de l'utilisateur.
// --- Section 1 : Général -------------------------------------------
$mform->addElement('header', 'generalheader', get_string('general'));
// Champ texte : titre de l'annonce.
$mform->addElement('text', 'title', get_string('title', 'local_annonces'),
['size' => 60, 'maxlength' => 255]);
$mform->setType('title', PARAM_TEXT); // OBLIGATOIRE (section 4).
$mform->addRule('title', get_string('required'), 'required', null, 'client');
$mform->addRule('title', get_string('maximumchars', '', 255), 'maxlength', 255, 'client');
// Liste déroulante : catégorie.
$mform->addElement('select', 'categoryid', get_string('category', 'local_annonces'),
$categories);
$mform->setType('categoryid', PARAM_INT);
$mform->setDefault('categoryid', 0);
// Case à cocher, uniquement si l'utilisateur en a la capacité.
// La logique métier (has_capability) vit dans la PAGE, pas ici :
// definition() ne fait que consommer le résultat via _customdata.
if ($canpin) {
$mform->addElement('advcheckbox', 'pinned',
get_string('pinned', 'local_annonces'), '', [], [0, 1]);
$mform->setType('pinned', PARAM_INT);
$mform->addHelpButton('pinned', 'pinned', 'local_annonces');
}
// --- Section 2 : Publication ---------------------------------------
$mform->addElement('header', 'publishheader',
get_string('publication', 'local_annonces'));
$mform->setExpanded('publishheader', false); // Section repliée par défaut.
// Date de publication, désactivable ("Enable" checkbox intégrée).
$mform->addElement('date_time_selector', 'timepublished',
get_string('timepublished', 'local_annonces'), ['optional' => true]);
// --- Champs cachés et boutons ---------------------------------------
// L'id de l'enregistrement (0 = création) et le cours, transportés
// silencieusement entre affichage et soumission.
$mform->addElement('hidden', 'id');
$mform->setType('id', PARAM_INT);
$mform->addElement('hidden', 'courseid', $courseid);
$mform->setType('courseid', PARAM_INT);
// Boutons Enregistrer / Annuler standardisés.
$this->add_action_buttons(true, get_string('savechanges'));
}
}Ce squelette montre déjà les invariants : $mform en première ligne, header pour structurer, setType sur chaque champ, chaînes via get_string() (jamais de texte en dur — l’internationalisation est traitée au chapitre sur les chaînes de langue), champs cachés typés, et add_action_buttons() pour finir.
⚠️ Piège :
definition()est appelée à chaque instanciation, y compris lors de la soumission (Moodle doit reconstruire le formulaire pour valider les données reçues). Si vos options deselectdépendent de la base de données, elles doivent être identiques à l’affichage et à la soumission, sinon QuickForm rejettera silencieusement la valeur soumise (« value not in options »). C’est la raison d’être de_customdata: la page calcule les options une fois et les passe au constructeur dans les deux phases.
3. Tour complet des éléments addElement()
La signature générale est :
$mform->addElement($type, $name, $label, $options_ou_attributs, ...);Passons en revue tous les éléments utiles, avec un exemple fonctionnel pour chacun.
3.1 Champs texte
// text : champ texte simple. 4e argument = attributs HTML (string ou array).
$mform->addElement('text', 'fullname', get_string('fullname'),
['size' => 50, 'maxlength' => 100, 'placeholder' => get_string('fullname')]);
$mform->setType('fullname', PARAM_TEXT);
// textarea : zone de texte multiligne SANS mise en forme (pas d'éditeur riche).
$mform->addElement('textarea', 'notes', get_string('notes', 'local_foo'),
['rows' => 6, 'cols' => 60]);
$mform->setType('notes', PARAM_TEXT);
// passwordunmask : mot de passe avec bouton "révéler". À préférer à 'password'
// (l'élément 'password' brut existe mais l'unmask est le standard Moodle).
$mform->addElement('passwordunmask', 'apikey', get_string('apikey', 'local_foo'));
$mform->setType('apikey', PARAM_RAW_TRIMMED); // Un secret ne doit PAS être altéré.
// float : nombre décimal respectant le séparateur de la locale (virgule en FR !).
// Exception à la règle : NE PAS appeler setType() sur un élément float,
// il gère lui-même la conversion locale → float PHP.
$mform->addElement('float', 'weight', get_string('weight', 'local_foo'), ['size' => 6]);
$mform->setDefault('weight', 1.0);⚠️ Piège : n’utilisez jamais
text+PARAM_FLOATpour un nombre décimal : un utilisateur français tapera3,14etclean_param()tronquera à3. L’élément dédiéfloat(Moodle 3.7+) gère le séparateur décimal de la locale à l’affichage et à la soumission. Et surtout, ne lui appliquez pas desetType: il ferait doublon et casserait la conversion.
3.2 Listes et sélection
// select simple.
$mform->addElement('select', 'format', get_string('format'), [
0 => get_string('formatplain', 'local_foo'),
1 => get_string('formathtml', 'local_foo'),
]);
$mform->setType('format', PARAM_INT);
// select multiple : ajoutez l'attribut 'multiple'. La valeur soumise est un tableau.
$select = $mform->addElement('select', 'roles', get_string('roles'), $rolesmenu);
$select->setMultiple(true);
// Pas de setType sur un select multiple retournant un tableau d'entiers connus :
// les valeurs sont validées contre la liste d'options. Vous POUVEZ mettre PARAM_INT,
// il sera appliqué à chaque élément du tableau.
// autocomplete : le select moderne, avec recherche. Remplace 'select' dès que
// la liste dépasse ~15 entrées.
$options = [
'multiple' => true,
'noselectionstring' => get_string('allareas', 'search'),
'placeholder' => get_string('search'),
];
$mform->addElement('autocomplete', 'userids', get_string('users'), $usersmenu, $options);
// autocomplete avec AJAX : les options sont chargées dynamiquement par un module
// AMD qui implémente transport() et processResults(). Idéal pour chercher parmi
// des milliers d'utilisateurs sans tout charger dans la page.
$options = [
'multiple' => true,
'ajax' => 'core_user/form_user_selector', // Module AMD fournissant la recherche.
'valuehtmlcallback' => function($userid) {
// Rend le libellé des valeurs déjà sélectionnées (mode édition).
$user = \core_user::get_user($userid);
return fullname($user);
},
];
$mform->addElement('autocomplete', 'recipients',
get_string('recipients', 'local_foo'), [], $options);
// autocomplete avec tags : l'utilisateur peut saisir des valeurs libres,
// pas seulement choisir dans la liste.
$mform->addElement('autocomplete', 'keywords', get_string('keywords', 'local_foo'),
$existingkeywords, ['multiple' => true, 'tags' => true]);💡 Pour un dev React :
autocompleteavec'ajax' => 'mon_plugin/mon_selector'est l’analogue direct d’un composantreact-select/async: le module AMD référencé doit exposertransport(selector, query, callback)(l’appel réseau, généralement un web service AJAX) etprocessResults(selector, results)(le mapping vers{value, label}). Le modulecore_user/form_user_selectordu noyau est un excellent modèle à copier.
3.3 Cases à cocher et boutons radio
// checkbox : simple, mais ATTENTION — si décochée, le navigateur n'envoie RIEN,
// donc la clé est absente des données soumises.
$mform->addElement('checkbox', 'sendnotification', get_string('notify', 'local_foo'));
// advcheckbox : LA case à cocher à utiliser. Elle envoie TOUJOURS une valeur
// grâce à un champ caché jumelé : ici 0 si décochée, 1 si cochée.
$mform->addElement('advcheckbox', 'visible', get_string('visible'),
get_string('visibledesc', 'local_foo'), // Texte à droite de la case.
[], // Attributs HTML.
[0, 1]); // [valeur décochée, valeur cochée].
$mform->setType('visible', PARAM_INT);
$mform->setDefault('visible', 1);
// radio : toujours en GROUPE, sinon l'alignement et les labels sont cassés.
$radioarray = [];
$radioarray[] = $mform->createElement('radio', 'mode', '',
get_string('modeauto', 'local_foo'), 'auto');
$radioarray[] = $mform->createElement('radio', 'mode', '',
get_string('modemanual', 'local_foo'), 'manual');
$mform->addGroup($radioarray, 'modegroup', get_string('mode', 'local_foo'),
['<br>'], false); // false = les éléments gardent leur propre nom ('mode').
$mform->setDefault('mode', 'auto');
$mform->setType('mode', PARAM_ALPHA);⚠️ Piège : avec
checkbox(le simple),$data->sendnotificationn’existe pas si la case est décochée — unif ($data->sendnotification)lèvera un warning PHP 8.3 sur propriété indéfinie. Utilisezempty($data->sendnotification)ou, mieux,advcheckboxqui garantit la présence de la clé. En pratique,advcheckboxdevrait être votre choix par défaut,checkboxune relique.
3.4 Dates et durées
// date_selector : jour/mois/année.
$mform->addElement('date_selector', 'startdate', get_string('startdate', 'local_foo'));
$mform->setDefault('startdate', time());
// date_time_selector : idem + heure/minute.
// 'optional' => true ajoute une case "Activer" : si décochée, la valeur soumise est 0.
$mform->addElement('date_time_selector', 'deadline',
get_string('deadline', 'local_foo'),
['optional' => true, 'startyear' => 2020, 'stopyear' => 2035]);
// duration : un nombre + une unité (secondes/minutes/heures/jours/semaines).
// La valeur soumise est TOUJOURS en secondes.
$mform->addElement('duration', 'timelimit', get_string('timelimit', 'local_foo'),
['optional' => true, 'defaultunit' => MINSECS]);
$mform->setDefault('timelimit', 30 * MINSECS);Les sélecteurs de date soumettent un timestamp Unix (entier, secondes UTC ajustées au fuseau de l’utilisateur) — cohérent avec le stockage en BIGINT vu au chapitre 02. Pas de setType nécessaire : ces éléments composites exportent directement un entier.
3.5 Structure, texte statique et HTML
// header : ouvre une SECTION repliable. Tous les éléments suivants y appartiennent
// jusqu'au prochain header.
$mform->addElement('header', 'advancedheader', get_string('advanced'));
$mform->setExpanded('advancedheader', false); // Repliée par défaut.
// setExpanded('...', true, true) : 3e argument = l'utilisateur ne peut pas
// l'avoir déjà repliée lui-même (ignore la préférence utilisateur).
// static : un couple label + texte non éditable. Pratique pour afficher une
// information contextuelle dans l'alignement du formulaire.
$mform->addElement('static', 'currentowner', get_string('owner', 'local_foo'),
fullname($owner));
// html : du HTML brut, pleine largeur, hors grille label/champ.
// À utiliser avec parcimonie (et via le renderer/les templates si possible).
$mform->addElement('html', \html_writer::div(
get_string('warningbanner', 'local_foo'), 'alert alert-warning'));
// hidden : champ caché. TOUJOURS suivi d'un setType (c'est une entrée
// utilisateur comme une autre : un attaquant peut modifier sa valeur !).
$mform->addElement('hidden', 'returnurl', $returnurl);
$mform->setType('returnurl', PARAM_LOCALURL);⚠️ Piège : un champ
hiddenn’a rien de secret ni de sûr. Il transite par le HTML et peut être modifié par n’importe qui avec les outils développeur. Typez-le strictement (PARAM_INT,PARAM_LOCALURL…) et re-vérifiez côté serveur les permissions liées (par exemple, que l’utilisateur a bien le droit d’éditer l’enregistrement dont l’idest passé en hidden). LesetTypenettoie le format, pas l’autorisation.
3.6 Boutons et groupes
// Boutons individuels (rarement utilisés seuls — préférez add_action_buttons).
$mform->addElement('submit', 'submitbutton', get_string('savechanges'));
$mform->addElement('cancel', 'cancelbutton', get_string('cancel'));
$mform->addElement('button', 'previewbutton', get_string('preview')); // Bouton JS pur.
// Un bouton submit qui NE déclenche PAS la validation ni get_data :
// utile pour les boutons "recharger une partie du formulaire".
$mform->registerNoSubmitButton('reloadoptions');
$mform->addElement('submit', 'reloadoptions', get_string('reload', 'local_foo'));
// addGroup : aligne plusieurs éléments sur UNE ligne, sous UN label.
$group = [];
$group[] = $mform->createElement('text', 'quantity', '', ['size' => 4]);
$group[] = $mform->createElement('select', 'unit', '', [
'kg' => 'kg', 'g' => 'g', 'l' => 'l',
]);
$mform->addGroup($group, 'quantitygroup', get_string('quantity', 'local_foo'),
' ', false); // Séparateur ' ', false = noms non préfixés par le nom du groupe.
$mform->setType('quantity', PARAM_INT);
// add_action_buttons : LE standard. Génère "Enregistrer" + "Annuler" dans une
// barre collante en bas de page (Moodle 4/5). Toujours en DERNIER dans definition().
$this->add_action_buttons(true, get_string('savechanges'));
// add_action_buttons(false) : bouton Enregistrer seul, sans Annuler.Le 5ᵉ argument d’addGroup mérite attention : à true, les éléments sont nommés quantitygroup[quantity] et arrivent dans $data->quantitygroup['quantity'] ; à false, ils gardent leurs noms plats ($data->quantity). Les règles sur un groupe s’appliquent via addGroupRule().
3.7 Fichiers et contenu riche (aperçu)
Ces trois éléments sont si particuliers qu’ils ont leur propre section (8). Pour mémoire :
// editor : éditeur riche (TinyMCE) avec fichiers embarqués.
$mform->addElement('editor', 'message_editor', get_string('message', 'local_foo'),
null, $editoroptions);
$mform->setType('message_editor', PARAM_RAW); // Cas légitime de PARAM_RAW !
// filepicker : UN fichier, sans gestion (typiquement pour un import ponctuel).
$mform->addElement('filepicker', 'importfile', get_string('file'), null,
['maxbytes' => $CFG->maxbytes, 'accepted_types' => ['.csv']]);
// filemanager : PLUSIEURS fichiers persistants, avec dossiers.
$mform->addElement('filemanager', 'attachments_filemanager',
get_string('attachments', 'local_foo'), null,
['subdirs' => 0, 'maxbytes' => $CFG->maxbytes, 'maxfiles' => 10,
'accepted_types' => ['document', 'image']]);3.8 Éléments « métier » Moodle
// course : sélecteur de cours avec recherche AJAX intégrée (autocomplete spécialisé).
$mform->addElement('course', 'targetcourse', get_string('course'),
['multiple' => false, 'includefrontpage' => false,
'requiredcapabilities' => ['moodle/course:update']]);
// tags : intégration avec la Tag API du noyau (suggestions de tags standards).
$mform->addElement('tags', 'tags', get_string('tags'),
['itemtype' => 'annonce', 'component' => 'local_annonces']);
// recaptcha : le captcha configuré au niveau du site (clés dans l'admin).
// À n'afficher que si signup_captcha_enabled() / configuration présente.
if (!empty($CFG->recaptchapublickey) && !empty($CFG->recaptchaprivatekey)) {
$mform->addElement('recaptcha', 'recaptcha_element', get_string('security_question', 'auth'));
$mform->addHelpButton('recaptcha_element', 'recaptcha', 'auth');
}
// Dans validation() : $this->_form->getElement('recaptcha_element')->verify(...)
// via $errors — voir le formulaire de signup du noyau (login/signup_form.php).📚 Aller plus loin : la liste exhaustive des éléments et leurs options vit sur https://moodledev.io/docs/5.2/apis/subsystems/form , et le code source de chaque élément dans
lib/form/(un fichier par élément :text.php,autocomplete.php,editor.php…). Lirelib/form/autocomplete.phpvous apprendra plus que n’importe quel tutoriel.
4. setType() : obligatoire, pas optionnel
4.1 Pourquoi
À la soumission, chaque valeur reçue de $_POST passe par clean_param($valeur, $type) avec le type déclaré via setType(). C’est la dernière ligne de défense contre les données malveillantes ou malformées : suppression de tags, cast en entier, validation de format… Sans type déclaré pour un élément textuel (text, textarea, hidden, password, url…), Moodle en mode développeur affiche le célèbre message :
Did you remember to call setType() for 'title'? Defaulting to PARAM_RAW cleaning.En production, le champ est traité en PARAM_RAW (aucun nettoyage) — une bombe à retardement XSS si la valeur est réaffichée sans échappement quelque part.
4.2 Les types essentiels
| Constante | Effet | Usage typique |
|---|---|---|
PARAM_INT | Cast entier | ids, timestamps, compteurs |
PARAM_FLOAT | Cast flottant (format machine) | via l’élément float de préférence |
PARAM_ALPHA | Lettres a-z A-Z uniquement | codes internes, valeurs d’enum |
PARAM_ALPHANUMEXT | Lettres, chiffres, _- | clés, identifiants techniques |
PARAM_TEXT | Texte multilingue, tags HTML supprimés | titres, noms, libellés |
PARAM_NOTAGS | Tout HTML strippé | proche de TEXT, plus strict |
PARAM_EMAIL | Email valide ou chaîne vide | adresses |
PARAM_URL / PARAM_LOCALURL | URL valide / URL interne au site | liens, redirections |
PARAM_RAW | Aucun nettoyage | contenu d’editor, secrets |
PARAM_RAW_TRIMMED | Aucun nettoyage sauf trim | clés d’API, tokens |
$mform->setType('title', PARAM_TEXT); // Un titre : jamais de HTML dedans.
$mform->setType('id', PARAM_INT); // Un id : entier, point final.
$mform->setType('email', PARAM_EMAIL); // Rejeté (vidé) si invalide.
$mform->setType('message_editor', PARAM_RAW); // L'éditeur produit du HTML : légitime.⚠️ Piège —
PARAM_TEXTvsPARAM_RAWpour du HTML :PARAM_TEXTsupprime les balises HTML. Si vous typez le champ d’uneditorenPARAM_TEXT, tout le contenu riche saisi par l’utilisateur (gras, liens, images) sera silencieusement détruit à la soumission. Inversement, typer un simpletextenPARAM_RAW« pour être tranquille » stocke du HTML potentiellement hostile. Règle d’or :PARAM_RAWuniquement pour les élémentseditor(le HTML y est ensuite assaini à l’affichage parformat_text(), vue au chapitre Output API) et les secrets qu’on ne réaffiche jamais.
💡 Pour un dev React :
setType()est votrez.coerce.number()/z.string(): un contrat de type appliqué à la frontière entre le monde extérieur (POST) et votre code. Comme avec zod, la philosophie est parse, don’t validate — la donnée qui sort deget_data()est déjà nettoyée et castée, vous ne manipulez jamais du$_POSTbrut. La différence culturelle : zod rejette une donnée invalide,clean_param()la nettoie (strip, cast, tronque) sans lever d’erreur — d’où l’importance de la validation explicite (addRule,validation()) en complément.
5. Règles, valeurs par défaut, aide et affichage conditionnel
5.1 addRule() : validation déclarative
// Signature : addRule($element, $message, $type, $format, $validation)
// $validation : 'client' (JS + re-vérifié serveur) ou 'server' (défaut, serveur seul).
$mform->addRule('title', get_string('required'), 'required', null, 'client');
$mform->addRule('title', get_string('maximumchars', '', 255), 'maxlength', 255, 'client');
$mform->addRule('quantity', get_string('err_numeric', 'form'), 'numeric', null, 'client');
$mform->addRule('email', get_string('invalidemail'), 'email', null, 'client');
$mform->addRule('code', get_string('err_alphanumeric', 'form'), 'alphanumeric', null, 'client');
$mform->addRule('slug', get_string('invalidslug', 'local_foo'), 'regex',
'/^[a-z0-9\-]+$/', 'client');
$mform->addRule('attempts', get_string('err_nonzero', 'form'), 'nonzero', null, 'server');Rules disponibles : required, maxlength, minlength, rangelength, email, regex, lettersonly, alphanumeric, numeric, nopunctuation, nonzero, compare, callback. Le mode 'client' génère de la validation JavaScript instantanée, mais la même règle est toujours re-vérifiée côté serveur : le JS n’est qu’un confort, jamais une sécurité. Le mode 'server' saute l’étape JS (utile quand la règle serait fausse côté client).
Notez les messages d’erreur standards du composant form (err_numeric, err_maxlength…) : réutilisez-les avant d’inventer vos propres chaînes.
5.2 setDefault() et setConstant()
$mform->setDefault('visible', 1); // Valeur initiale si set_data ne fournit rien.
$mform->setDefault('startdate', time());
$mform->setConstant('courseid', $courseid); // Force la valeur, IGNORE ce qui est soumis.setDefault() est écrasé par set_data() (section 7) : c’est le défaut « formulaire vierge ». setConstant() est plus fort : la valeur soumise par le navigateur est ignorée et remplacée — une protection utile pour les champs cachés sensibles.
5.3 addHelpButton() : l’icône « ? »
$mform->addHelpButton('pinned', 'pinned', 'local_annonces');Cette ligne exige deux chaînes dans lang/en/local_annonces.php :
$string['pinned'] = 'Pin this announcement';
$string['pinned_help'] = 'A pinned announcement always appears at the top of the list,
regardless of its publication date.';La convention identifiant + identifiant_help est vérifiée par les outils de CI Moodle. La chaîne _help s’affiche dans un popover au clic sur l’icône, et supporte le Markdown.
5.4 disabledIf() et hideIf() : le rendu conditionnel
// Signature : disabledIf($element, $dependenton, $condition, $value)
// hideIf a la même signature (Moodle 3.4+) mais CACHE au lieu de griser.
// Griser 'deadline' si la case 'hasdeadline' n'est pas cochée.
$mform->disabledIf('deadline', 'hasdeadline', 'notchecked');
// Cacher 'notifymessage' tant que 'sendnotification' n'est pas cochée.
$mform->hideIf('notifymessage', 'sendnotification', 'notchecked');
// Tous les opérateurs disponibles :
$mform->hideIf('a', 'mode', 'eq', 'auto'); // mode == 'auto'
$mform->hideIf('b', 'mode', 'neq', 'manual'); // mode != 'manual'
$mform->hideIf('c', 'confirm', 'checked'); // case cochée
$mform->hideIf('d', 'confirm', 'notchecked'); // case décochée
$mform->hideIf('e', 'categoryid', 'in', [2, 5, 9]); // valeur parmi la liste
$mform->hideIf('f', 'userids', 'noitemselected'); // autocomplete vide
// Fonctionne aussi sur un GROUPE entier ou une valeur de groupe :
$mform->hideIf('quantitygroup', 'mode', 'eq', 'auto');Ces conditions sont évaluées en JavaScript, côté client uniquement. Un champ caché par hideIf est quand même soumis avec le formulaire — sa valeur arrive dans get_data(). Si un champ caché ne doit pas être pris en compte, ignorez-le explicitement dans le traitement, ou vérifiez la condition dans validation().
💡 Pour un dev React :
hideIf/disabledIfsont le pendant déclaratif de{watch('sendnotification') && <NotifyMessage />}. La différence fondamentale : en React, un composant non rendu ne soumet rien ; en moodleform, le champ existe toujours dans le DOM (masqué en CSS) et sa valeur est soumise. C’est un piège classique de croire qu’un champhideIf-é est « absent » — pensez-y comme un<div hidden>, pas comme un rendu conditionnel.
5.5 freeze() et setAdvanced()
// freeze : affiche la valeur en lecture seule (le champ n'est plus éditable).
// hardFreeze (défaut de freeze) : la valeur n'est même plus soumise.
if (!$caneditcategory) {
$mform->freeze('categoryid');
}
// setAdvanced : le champ est masqué derrière le lien "Afficher plus…"
// de sa section. Pour les options pointues que 95% des utilisateurs ignorent.
$mform->setAdvanced('customcss', true);⚠️ Piège : un champ gelé (
freeze) ne soumet pas sa valeur ; à la mise à jour de l’enregistrement, si vous faites un$DB->update_record()avec l’objet complet deget_data(), la propriété absente peut écraser la colonne avecnullselon votre code. Relisez la valeur d’origine en base pour les champs gelés, ou utilisezsetConstant()qui, lui, garantit la valeur dans les données.
6. Validation custom : validation($data, $files)
Quand addRule() ne suffit plus (unicité, cohérence entre champs, vérification en base), surchargez validation() :
/**
* Validation serveur personnalisée.
*
* @param array $data Données soumises, DÉJÀ nettoyées par setType (tableau, pas objet !).
* @param array $files Fichiers soumis.
* @return array Tableau nomduchamp => message d'erreur. Vide = formulaire valide.
*/
public function validation($data, $files) {
global $DB;
// TOUJOURS commencer par la validation du parent (règles addRule serveur, etc.).
$errors = parent::validation($data, $files);
// 1. Unicité en base de données : pas deux annonces avec le même titre
// dans le même cours (en excluant l'enregistrement en cours d'édition).
$params = ['title' => $data['title'], 'courseid' => $data['courseid']];
$existing = $DB->get_record('local_annonces', $params, 'id', IGNORE_MULTIPLE);
if ($existing && (int) $existing->id !== (int) $data['id']) {
$errors['title'] = get_string('errduplicatetitle', 'local_annonces');
}
// 2. Cohérence de dates : la date limite doit suivre la publication.
// Rappel : un date_time_selector 'optional' décoché vaut 0.
if (!empty($data['deadline']) && !empty($data['timepublished'])
&& $data['deadline'] <= $data['timepublished']) {
// L'erreur s'affiche sous le champ fautif.
$errors['deadline'] = get_string('errdeadlinebeforepublish', 'local_annonces');
}
// 3. Validation croisée : le mode 'manual' exige au moins un destinataire.
if ($data['mode'] === 'manual' && empty($data['recipients'])) {
$errors['recipients'] = get_string('errnorecipients', 'local_annonces');
}
// 4. Règle métier avec paramètre dans le message :
// get_string accepte un 3e argument injecté dans {$a}.
$max = get_config('local_annonces', 'maxpinned');
if (!empty($data['pinned'])) {
$count = $DB->count_records('local_annonces',
['courseid' => $data['courseid'], 'pinned' => 1]);
if ($count >= $max) {
$errors['pinned'] = get_string('errtoomanypinned', 'local_annonces', $max);
}
}
return $errors;
}Points de vigilance :
$dataest un tableau ($data['title']), contrairement àget_data()qui retourne un objet ($data->title). Erreur de débutant classique.- Retourner un tableau vide signifie « valide ». Si
$errorsest non vide,get_data()retourneranullet le formulaire sera réaffiché avec les messages sous chaque champ concerné. - La clé du tableau doit être le nom exact d’un élément (ou d’un groupe) pour que le message s’affiche au bon endroit. Une clé inconnue rend l’erreur invisible — le formulaire boucle sans explication.
- Les messages passent par
get_string(): jamais de texte en dur.
⚠️ Piège : oublier
$errors = parent::validation($data, $files);et démarrer avec$errors = [];désactive silencieusement des validations du noyau (dont certaines règles serveur et la validation de certains éléments composites). Prenez le réflexe : première ligne, appel au parent.
7. Le cycle de traitement complet : le pattern canonique
Voici LE pattern que vous écrirez des dizaines de fois. Une page edit.php qui crée ou modifie une annonce :
<?php
// Fichier : local/annonces/edit.php
require_once(__DIR__ . '/../../config.php'); // Bootstrap Moodle (chapitre 01).
use local_annonces\form\annonce_form;
// --- 1. Paramètres d'entrée, contexte, sécurité -----------------------------
$courseid = required_param('courseid', PARAM_INT);
$id = optional_param('id', 0, PARAM_INT); // 0 = création.
$course = get_course($courseid);
require_login($course); // Authentification + inscription.
$context = context_course::instance($courseid);
require_capability('local/annonces:manage', $context);
$returnurl = new moodle_url('/local/annonces/index.php', ['courseid' => $courseid]);
$pageurl = new moodle_url('/local/annonces/edit.php',
['courseid' => $courseid, 'id' => $id]);
$PAGE->set_url($pageurl);
$PAGE->set_context($context);
$PAGE->set_title(get_string('editannonce', 'local_annonces'));
$PAGE->set_heading($course->fullname);
// --- 2. Préparer les customdata et instancier le formulaire -----------------
$categories = local_annonces_get_categories_menu($courseid);
$canpin = has_capability('local/annonces:pin', $context);
// 1er argument : l'URL d'action (null = la page courante, le cas usuel).
// 2e argument : les customdata consommées par definition().
$mform = new annonce_form($pageurl, [
'courseid' => $courseid,
'categories' => $categories,
'canpin' => $canpin,
]);
// --- 3. Les trois branches du cycle de vie ----------------------------------
if ($mform->is_cancelled()) {
// (a) Bouton Annuler cliqué : on repart SANS RIEN TRAITER.
// Nota : is_cancelled() ne vérifie ni sesskey ni validation — normal,
// on ne touche à aucune donnée.
redirect($returnurl);
} else if ($data = $mform->get_data()) {
// (b) Formulaire soumis ET valide. get_data() garantit à ce stade :
// - le sesskey a été vérifié (protection CSRF) ;
// - chaque champ est nettoyé selon son setType ;
// - toutes les règles addRule + validation() sont passées.
// $data est un OBJET stdClass.
if (empty($data->id)) {
// Création.
$data->timecreated = time();
$data->timemodified = time();
$data->usercreated = $USER->id;
$data->id = $DB->insert_record('local_annonces', $data);
$message = get_string('annoncecreated', 'local_annonces');
} else {
// Mise à jour : re-vérifier que l'enregistrement cible existe et
// appartient bien au cours (le hidden 'id' est falsifiable !).
$DB->get_record('local_annonces',
['id' => $data->id, 'courseid' => $courseid], '*', MUST_EXIST);
$data->timemodified = time();
$DB->update_record('local_annonces', $data);
$message = get_string('annonceupdated', 'local_annonces');
}
// TOUJOURS rediriger après un POST réussi (pattern Post/Redirect/Get) :
// c'est la protection contre le double-submit par rafraîchissement (F5).
redirect($returnurl, $message, null, \core\output\notification::NOTIFY_SUCCESS);
}
// --- 4. Pré-remplissage en mode édition --------------------------------------
if ($id) {
$entry = $DB->get_record('local_annonces',
['id' => $id, 'courseid' => $courseid], '*', MUST_EXIST);
// set_data mappe chaque propriété de l'objet sur l'élément du même nom.
$mform->set_data($entry);
}
// (En création, les setDefault de definition() s'appliquent.)
// --- 5. Affichage -------------------------------------------------------------
// On n'arrive ici QUE si : premier affichage, OU soumission invalide
// (le formulaire se réaffiche alors avec les erreurs et les valeurs saisies).
echo $OUTPUT->header();
echo $OUTPUT->heading(get_string(empty($id) ? 'addannonce' : 'editannonce', 'local_annonces'));
$mform->display();
echo $OUTPUT->footer();7.1 Pourquoi get_data() retourne null
get_data() retourne null dans tous les cas suivants :
- Le formulaire n’a pas été soumis (premier affichage en GET).
- Le formulaire a été annulé (mais testez
is_cancelled()avant, sinon l’annulation tombe dans l’affichage). - Le
sesskeysoumis ne correspond pas à la session (tentative CSRF ou session expirée). - Une règle
addRuleserveur a échoué, ouvalidation()a retourné des erreurs. - Un bouton « no-submit » (enregistré via
registerNoSubmitButton()) a déclenché la soumission.
Cette sémantique rend le pattern robuste : le seul chemin qui mène au traitement des données est un chemin où tout est vérifié. Il n’existe pas de moyen d’obtenir des données non validées par cette API (contrairement à data_submitted(), un utilitaire bas niveau à fuir).
7.2 no_submit_button_pressed() et les rechargements partiels
Quand un formulaire contient un bouton qui doit modifier le formulaire lui-même sans le traiter (ex. « Ajouter 3 options » des repeat elements, section 9), ce bouton est enregistré via registerNoSubmitButton(). À son clic, get_data() retourne null mais no_submit_button_pressed() retourne true — le flux retombe naturellement sur display(), et definition() (ré-exécutée) peut lire les valeurs soumises pour se reconstruire différemment. C’est le mécanisme « server-driven re-render » de Moodle.
} else if ($data = $mform->get_data()) {
// Traitement normal…
}
// Si no_submit_button_pressed(), on retombe ici et le formulaire
// se réaffiche, reconstruit avec ses nouvelles répétitions.
$mform->display();💡 Pour un dev React : ce cycle est un server-side controlled component. Chaque interaction structurante (ajouter une ligne, recharger des options) fait un aller-retour complet : POST → reconstruction de
definition()avec le nouvel état → re-render HTML. Là où React re-rend un arbre virtuel en mémoire, Moodle re-rend la page entière côté serveur. C’est moins fluide, mais l’état vit à un seul endroit (le POST + la base), sans synchronisation client/serveur à gérer.
⚠️ Piège — l’ordre des branches est impératif :
is_cancelled()d’abord,get_data()ensuite,set_data()+display()en dernier. Testerget_data()avantis_cancelled()traiterait une annulation comme une soumission dans certains cas limites. Et appelerset_data()avant le blocget_data()est inutile mais inoffensif ; l’appeler aprèsdisplay()ne fait évidemment rien. Mémorisez la séquence : cancelled → data → set_data → display.
📚 Aller plus loin : le tutoriel officiel « Form API — Usage » sur https://moodledev.io/docs/5.2/apis/subsystems/form/usage déroule ce même pattern pas à pas, et la page « Advanced usage » couvre les cas tordus (formulaires multiples sur une page, préfixes d’identifiants, etc.).
8. Éditeur, filepicker, filemanager : le trio des draft areas
C’est la partie la plus déroutante de la Form API. Les fichiers dans Moodle ne sont jamais uploadés « directement » dans leur zone définitive : ils transitent par une zone brouillon (draft area) liée à l’utilisateur, puis sont déplacés vers leur zone finale au moment de l’enregistrement. Ce chapitre donne les recettes ; le fonctionnement profond de la File API (contextes, composants, fileareas, itemids) est détaillé au chapitre 05.
Le flux général, identique pour l’éditeur et le filemanager :
ÉDITION (avant set_data) ENREGISTREMENT (après get_data)
───────────────────────── ────────────────────────────────
fichiers zone finale fichiers zone brouillon
│ file_prepare_standard_* │ file_postupdate_standard_*
▼ ▼
copiés en zone brouillon déplacés en zone finale
+ URLs réécrites (@@PLUGINFILE@@ → draft) + URLs réécrites (draft → @@PLUGINFILE@@)8.1 L’élément editor, de bout en bout
Convention de nommage cruciale : pour un contenu stocké en base dans les colonnes intro (TEXT) et introformat (INT), l’élément du formulaire s’appelle intro_editor. Les fonctions _standard_ s’appuient sur ce suffixe.
// ---- Dans definition() ------------------------------------------------------
$editoroptions = $this->_customdata['editoroptions'];
$mform->addElement('editor', 'intro_editor', get_string('description'),
null, $editoroptions);
$mform->setType('intro_editor', PARAM_RAW); // Légitime : c'est du HTML.
$mform->addRule('intro_editor', get_string('required'), 'required', null, 'client');// ---- Dans la page edit.php ---------------------------------------------------
// Options d'éditeur : à définir UNE FOIS et réutiliser partout (préparation,
// post-traitement, et affichage des fichiers). Toute incohérence = fichiers perdus.
$editoroptions = [
'maxfiles' => EDITOR_UNLIMITED_FILES, // Fichiers embarqués (images…) autorisés.
'maxbytes' => $CFG->maxbytes,
'trusttext' => false,
'subdirs' => false,
'context' => $context, // Le contexte de la zone de fichiers finale.
];
$mform = new annonce_form($pageurl, [
'courseid' => $courseid,
'editoroptions' => $editoroptions,
// ...
]);
if ($mform->is_cancelled()) {
redirect($returnurl);
} else if ($data = $mform->get_data()) {
if (empty($data->id)) {
// CRÉATION : problème d'œuf et de poule — les fichiers de la zone finale
// sont rangés par itemid = id de l'enregistrement… qui n'existe pas encore.
// Solution standard : insérer d'abord avec un contenu vide, puis post-traiter.
$data->intro = '';
$data->introformat = FORMAT_HTML;
$data->id = $DB->insert_record('local_annonces', $data);
}
// Déplace les fichiers du brouillon vers la zone finale
// (composant 'local_annonces', filearea 'intro', itemid = id de l'enregistrement)
// et transforme $data->intro_editor (array text/format/itemid) en
// $data->intro (string) + $data->introformat (int), URLs réécrites en @@PLUGINFILE@@.
$data = file_postupdate_standard_editor($data, 'intro', $editoroptions,
$context, 'local_annonces', 'intro', $data->id);
$DB->update_record('local_annonces', $data);
redirect($returnurl, get_string('changessaved'), null,
\core\output\notification::NOTIFY_SUCCESS);
}
// Pré-remplissage en mode édition (ET en création, avec un objet vierge !).
if ($id) {
$entry = $DB->get_record('local_annonces', ['id' => $id], '*', MUST_EXIST);
} else {
$entry = new stdClass();
$entry->id = null;
$entry->intro = '';
$entry->introformat = FORMAT_HTML;
}
// Copie les fichiers de la zone finale vers un brouillon et fabrique
// $entry->intro_editor = ['text' => ..., 'format' => ..., 'itemid' => $draftid].
$entry = file_prepare_standard_editor($entry, 'intro', $editoroptions,
$context, 'local_annonces', 'intro', $entry->id);
$mform->set_data($entry);
$mform->display();Trois choses à graver dans le marbre :
file_prepare_standard_editor()avantset_data(), même en création (avecitemid = null) — sinon les images collées dans l’éditeur n’auront pas de brouillon où atterrir.file_postupdate_standard_editor()aprèsget_data(), avec un id d’enregistrement existant — d’où le pattern « insérer vide puis mettre à jour » en création.- Les mêmes
$editoroptionspartout. Unmaxbytesdifférent entre préparation et post-traitement, et des fichiers disparaissent.
8.2 filemanager : pièces jointes persistantes
Même logique, suffixe _filemanager, fonctions jumelles :
// ---- definition() ----
$filemanageroptions = ['subdirs' => 0, 'maxbytes' => $CFG->maxbytes,
'maxfiles' => 10, 'accepted_types' => ['document', 'image']];
$mform->addElement('filemanager', 'attachments_filemanager',
get_string('attachments', 'local_annonces'), null, $filemanageroptions);
// ---- edit.php, avant set_data ----
$entry = file_prepare_standard_filemanager($entry, 'attachments',
$filemanageroptions, $context, 'local_annonces', 'attachments', $entry->id);
$mform->set_data($entry);
// ---- edit.php, après get_data (et insert si création) ----
$data = file_postupdate_standard_filemanager($data, 'attachments',
$filemanageroptions, $context, 'local_annonces', 'attachments', $data->id);La variante « manuelle », que vous verrez dans beaucoup de code du noyau, fait la même chose avec les briques de base — utile quand le nommage standard ne colle pas :
// AVANT set_data : préparer le brouillon à la main.
$draftitemid = file_get_submitted_draft_itemid('attachments_filemanager');
file_prepare_draft_area($draftitemid, $context->id, 'local_annonces', 'attachments',
$entry->id, $filemanageroptions);
$entry->attachments_filemanager = $draftitemid;
$mform->set_data($entry);
// APRÈS get_data : persister le brouillon.
file_save_draft_area_files($data->attachments_filemanager, $context->id,
'local_annonces', 'attachments', $data->id, $filemanageroptions);8.3 filepicker : le fichier jetable
Le filepicker sert aux imports ponctuels (un CSV à traiter, une archive à restaurer) : le fichier reste en brouillon, vous le lisez, vous le jetez. Pas de prepare/postupdate :
// Dans le traitement, deux approches :
// (a) Lire directement le contenu (petits fichiers) :
$content = $mform->get_file_content('importfile');
// (b) Obtenir un objet stored_file complet :
$files = $mform->get_draft_files('importfile');
if ($files) {
$file = reset($files);
$csv = $file->get_content();
// ... traiter, puis ne RIEN sauvegarder : le brouillon sera purgé par cron.
}⚠️ Piège : après un
file_postupdate_standard_editor(), l’objet$datacontient à la foisintro(le HTML final avec@@PLUGINFILE@@) et l’ancienintro_editor(le tableau brouillon).$DB->update_record()ignore les propriétés qui ne correspondent à aucune colonne, donc ça passe — mais si vous sérialisez$dataailleurs (événement, cache, web service), pensez à retirer les champs_editor/_filemanager. Et à l’affichage, n’oubliez pasfile_rewrite_pluginfile_urls()pour retransformer@@PLUGINFILE@@en vraies URLs (chapitre 05).
📚 Aller plus loin : la mécanique complète des draft areas, du callback
pluginfile.phpet desstored_fileest décrite dans https://moodledev.io/docs/5.2/apis/subsystems/files — et fait l’objet du chapitre 05 de cette documentation.
9. Repeat elements et formulaires dynamiques (dynamic_form)
9.1 repeat_elements() : des blocs de champs répétables
Le besoin classique : « permettre de saisir N options » (comme les choix d’un sondage). La Form API offre repeat_elements(), qui duplique un groupe d’éléments et gère le bouton « Ajouter » :
protected function definition() {
$mform = $this->_form;
// 1. Le "modèle" : les éléments à répéter, créés avec createElement
// (PAS addElement — on ne les ajoute pas directement).
$repeatarray = [];
$repeatarray[] = $mform->createElement('text', 'optiontext',
get_string('optionno', 'local_annonces', '{no}')); // {no} → numéro de ligne.
$repeatarray[] = $mform->createElement('float', 'optionweight',
get_string('weight', 'local_annonces'), ['size' => 4]);
$repeatarray[] = $mform->createElement('advcheckbox', 'optionenabled',
get_string('enabled', 'local_annonces'));
// Champ caché pour l'id de chaque option existante (mode édition).
$repeatarray[] = $mform->createElement('hidden', 'optionid', 0);
// 2. Nombre de répétitions initiales : les options existantes + 2 vierges,
// minimum 3. En édition, ce nombre vient des customdata.
$repeatcount = max(3, $this->_customdata['optioncount'] + 2);
// 3. Options par élément répété : type, défaut, règles, helpbutton,
// disabledif/hideif…
$repeatoptions = [
'optiontext' => [
'type' => PARAM_TEXT,
'helpbutton' => ['optiontext', 'local_annonces'],
// Règle appliquée à CHAQUE occurrence :
'rule' => ['maxlength', get_string('maximumchars', '', 255), 'maxlength', 255, 'client'],
],
'optionweight' => ['default' => 1.0],
'optionenabled' => ['type' => PARAM_INT, 'default' => 1],
'optionid' => ['type' => PARAM_INT],
];
// 4. La magie : génère optiontext[0], optiontext[1], …, le champ caché
// compteur, et le bouton no-submit "Ajouter 2 option(s)".
$this->repeat_elements(
$repeatarray, // Éléments modèles.
$repeatcount, // Répétitions initiales.
$repeatoptions, // Options par élément.
'option_repeats', // Nom du champ caché comptant les répétitions.
'option_add_fields', // Nom du bouton "ajouter" (auto-enregistré no-submit).
2, // Combien de lignes ajoute chaque clic.
get_string('addmoreoptions', 'local_annonces'), // Libellé du bouton.
true // Bouton DANS la section courante (pas une nouvelle).
);
$this->add_action_buttons();
}À la soumission, get_data() retourne des tableaux indexés : $data->optiontext[0], $data->optiontext[1]… Le traitement itère dessus :
if ($data = $mform->get_data()) {
foreach ($data->optiontext as $i => $text) {
$text = trim($text);
if ($text === '') {
continue; // Ligne vierge : ignorée (ou supprimée si optionid existant).
}
$option = (object) [
'id' => $data->optionid[$i],
'text' => $text,
'weight' => $data->optionweight[$i],
'enabled' => $data->optionenabled[$i],
'annonceid' => $annonce->id,
];
if ($option->id) {
$DB->update_record('local_annonces_options', $option);
} else {
unset($option->id);
$DB->insert_record('local_annonces_options', $option);
}
}
redirect($returnurl);
}Le bouton « Ajouter » est un no-submit button : son clic reposte le formulaire, no_submit_button_pressed() est vrai, definition() se ré-exécute avec le compteur incrémenté, et la page se réaffiche avec les lignes supplémentaires — le fameux re-render serveur. Depuis Moodle 4.x, un dernier paramètre optionnel $deletebuttonname permet même un bouton « Supprimer » par ligne.
Limites : pas de drag & drop pour réordonner, un aller-retour serveur complet par ajout, un markup lourd. Pour une UX moderne (ajout instantané, tri), il faut sortir de repeat_elements : soit du JS custom sur un formulaire classique, soit l’approche modale ci-dessous.
9.2 \core_form\dynamic_form : le formulaire en modal AJAX
Depuis Moodle 3.11, dynamic_form est la réponse officielle au besoin « formulaire dans une modal, soumis en AJAX, sans page dédiée ». C’est une sous-classe de moodleform qui ajoute le contrat nécessaire pour être chargée et soumise par le web service core_form_dynamic_form. Prérequis absolu : la classe doit être dans classes/form/ (elle est instanciée par son nom qualifié depuis le JS).
<?php
namespace local_annonces\form;
use context;
use context_course;
use moodle_url;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->libdir . '/formslib.php');
/**
* Formulaire d'annonce utilisable en modal AJAX.
*/
class annonce_dynamic_form extends \core_form\dynamic_form {
/**
* Définition classique — identique à un moodleform normal.
* Les "customdata" arrivent ici via $this->_ajaxformdata (les args passés
* par le JS), PAS via _customdata.
*/
protected function definition() {
$mform = $this->_form;
$mform->addElement('hidden', 'id');
$mform->setType('id', PARAM_INT);
$mform->addElement('hidden', 'courseid');
$mform->setType('courseid', PARAM_INT);
$mform->addElement('text', 'title', get_string('title', 'local_annonces'));
$mform->setType('title', PARAM_TEXT);
$mform->addRule('title', get_string('required'), 'required', null, 'client');
// PAS de add_action_buttons() : la modal fournit ses propres boutons
// Enregistrer/Annuler.
}
/**
* Le contexte dans lequel le formulaire opère. Sert de base aux
* vérifications et au chargement des fichiers.
*/
protected function get_context_for_dynamic_submission(): context {
$courseid = $this->optional_param('courseid', null, PARAM_INT);
return context_course::instance($courseid);
}
/**
* Contrôle d'accès : lève une exception si l'utilisateur courant n'a pas
* le droit d'afficher/soumettre ce formulaire. APPELÉ À CHAQUE requête AJAX.
*/
protected function check_access_for_dynamic_submission(): void {
require_capability('local/annonces:manage',
$this->get_context_for_dynamic_submission());
}
/**
* Traitement de la soumission valide (l'équivalent du bloc get_data()
* de edit.php). La valeur retournée est transmise telle quelle au JS.
*/
public function process_dynamic_submission() {
global $DB;
$data = $this->get_data();
if (empty($data->id)) {
$data->timecreated = time();
$data->id = $DB->insert_record('local_annonces', $data);
} else {
$DB->update_record('local_annonces', $data);
}
// Retour arbitraire, sérialisé en JSON pour l'événement FORM_SUBMITTED.
return ['id' => $data->id, 'title' => $data->title];
}
/**
* Pré-remplissage (l'équivalent du set_data() de edit.php),
* appelé au chargement AJAX du formulaire.
*/
public function set_data_for_dynamic_submission(): void {
global $DB;
$id = $this->optional_param('id', 0, PARAM_INT);
$data = ['id' => $id, 'courseid' => $this->optional_param('courseid', 0, PARAM_INT)];
if ($id) {
$entry = $DB->get_record('local_annonces', ['id' => $id], '*', MUST_EXIST);
$data['title'] = $entry->title;
}
$this->set_data($data);
}
/**
* URL de repli si le JS est indisponible (le formulaire y est affiché
* en pleine page) — pointez vers votre edit.php classique.
*/
protected function get_page_url_for_dynamic_submission(): moodle_url {
return new moodle_url('/local/annonces/edit.php', [
'courseid' => $this->optional_param('courseid', 0, PARAM_INT),
'id' => $this->optional_param('id', 0, PARAM_INT),
]);
}
}Côté JavaScript, le module core_form/modalform fait tout le travail :
// Fichier : local/annonces/amd/src/editmodal.js
import ModalForm from 'core_form/modalform';
import {getString} from 'core/str';
import Notification from 'core/notification';
export const init = (selector) => {
document.querySelectorAll(selector).forEach((trigger) => {
trigger.addEventListener('click', async (e) => {
e.preventDefault();
const modalForm = new ModalForm({
// Le nom COMPLET de la classe PHP (double backslash en JS).
formClass: 'local_annonces\\form\\annonce_dynamic_form',
// Les args deviennent les paramètres lus par optional_param()
// dans la classe (et _ajaxformdata dans definition()).
args: {
id: trigger.dataset.id ?? 0,
courseid: trigger.dataset.courseid,
},
modalConfig: {
title: await getString('editannonce', 'local_annonces'),
},
returnFocus: trigger, // Accessibilité : focus rendu au déclencheur.
});
// Le retour de process_dynamic_submission() arrive dans e.detail.
modalForm.addEventListener(modalForm.events.FORM_SUBMITTED, (e) => {
window.location.reload(); // Ou mise à jour fine du DOM avec e.detail.
});
modalForm.show();
});
});
};Le cycle complet : show() charge le HTML du formulaire via AJAX (set_data_for_dynamic_submission incluse), l’affiche dans une modal ; à la soumission, si validation() échoue, la modal réaffiche les erreurs sans rechargement ; si tout est valide, process_dynamic_submission() s’exécute et l’événement FORM_SUBMITTED est émis. La validation client, hideIf, les éditeurs et filemanagers fonctionnent dans la modal.
💡 Pour un dev React :
dynamic_form+modalform, c’est votre modal<Dialog>avec un formulaire react-hook-form soumis par mutation — sauf que le HTML du formulaire est rendu par le serveur et injecté.check_access_for_dynamic_submission()joue le rôle du middleware d’autorisation de votre route API : ne faites jamais confiance au fait que « le bouton n’était visible que pour les admins », chaque requête AJAX re-vérifie les droits.
⚠️ Piège : dans un
dynamic_form, ne lisez pas$this->_customdata: il est vide en contexte AJAX. Les paramètres passés dansargs:côté JS se récupèrent via$this->_ajaxformdata['id']ou, mieux,$this->optional_param('id', 0, PARAM_INT)qui fonctionne dans les deux modes (AJAX et pleine page de repli).
📚 Aller plus loin : la page « Modal and AJAX forms » de moodledev — https://moodledev.io/docs/5.2/apis/subsystems/form/modal — couvre aussi le chargement d’un
dynamic_forminline dans la page (modulecore_form/dynamicform), sans modal, pour des panneaux d’édition intégrés.
10. Le markup généré et ses limites
10.1 Ce que produit display()
Un moodleform rendu, c’est : un <form> avec classes mform, des <fieldset> par header, et pour chaque élément une structure imposée issue des templates Mustache core_form/element-* (dans lib/form/templates/) :
<div class="mb-3 row fitem" data-groupname="...">
<div class="col-md-3 col-form-label d-flex pb-0 pe-md-0">
<label class="d-inline word-break-all" for="id_title">Titre</label>
<div class="form-label-addon ...">
<!-- icône aide, badge "Obligatoire" -->
</div>
</div>
<div class="col-md-9 d-flex flex-wrap align-items-start felement" data-fieldtype="text">
<input type="text" class="form-control" name="title" id="id_title" ...>
<div class="form-control-feedback invalid-feedback" id="id_error_title"></div>
</div>
</div>Cette structure explique pourquoi les formulaires Moodle sont difficiles à styler finement :
- Grille Bootstrap imposée : le ratio label/champ
col-md-3/col-md-9est câblé dans les templates. Pas d’option « labels au-dessus » par formulaire. - IDs générés :
id_title,id_error_title, et dans les groupes des identifiants composites (id_quantitygroup_quantity) avec parfois des suffixes uniques quand plusieurs formulaires coexistent. Ne codez jamais un sélecteur JS/CSS en dur sur ces IDs : utilisezdata-fieldtype, les noms de champs, ou des classes que vous ajoutez vous-même via le tableau d’attributs d’addElement. - Pas de composition libre : impossible d’intercaler du markup arbitraire entre le label et le champ sans passer par un élément custom ou
static/html(avec leurs limites d’alignement).
10.2 Les points d’extension
Par ordre croissant d’effort :
- Attributs et classes custom :
addElement('text', 'x', $label, ['class' => 'local-foo-highlight'])— le plus simple, souvent suffisant. - Surcharge de templates dans un thème : copiez
lib/form/templates/element-template.mustacheverstheme/montheme/templates/core_form/element-template.mustacheet modifiez-le — tout le site change. C’est le mécanisme général de surcharge Mustache, détaillé dans la partie thèmes de cette documentation. - Élément de formulaire custom : une classe étendant
MoodleQuickForm_element(ou un élément existant), enregistrée viaMoodleQuickForm::registerElementType(), avec son propre template. C’est ainsi que sont faitsautocomplete,course, etc. - Renderer : le rendu passe par
core_rendereret le renderer QuickForm ; un thème peut le surcharger, mais c’est rarement le bon niveau — préférez les templates.
10.3 mform en AJAX : render() et les fragments
Outre dynamic_form (section 9), deux mécanismes servent à injecter un mform dans une page existante :
$mform->render()retourne le HTML au lieu de l’imprimer (display()n’est qu’unecho $this->render()), pratique pour l’inclure dans un template ou une réponse.- L’API Fragment (
$OUTPUT+ callbackcomponent_output_fragment_xxx()danslib.php, appelée côté JS parcore/fragment) : elle rend un morceau de page — typiquement un mform — via AJAX en incluant le JavaScript nécessaire (validation client, éditeur, filepicker), ce qu’un simplerender()renvoyé par web service ne ferait pas. C’était la méthode standard pré-dynamic_form, encore très présente dans le noyau (ex. l’édition rapide des questions de quiz).
Pour du code neuf : dynamic_form d’abord, fragments si vous devez injecter un formulaire dans un conteneur existant non modal, et jamais de HTML de formulaire concaténé à la main.
⚠️ Piège : ne rendez jamais un moodleform via un web service classique en renvoyant
$mform->render()dans une string : le JS d’initialisation des éléments riches (éditeur, autocomplete, filemanager) ne sera ni collecté ni exécuté, et vous obtiendrez des champs morts. C’est exactement le problème que résolvent les fragments etdynamic_form.
📚 Aller plus loin : « Output API — Fragments » sur moodledev (https://moodledev.io/docs/5.2/apis/subsystems/fragments ) et le chapitre suivant de cette documentation pour tout ce qui touche au rendu (renderers, templates Mustache,
$OUTPUT).
Résumé — points clés
moodleformest un wrapper au-dessus d’un fork embarqué de HTML_QuickForm (PEAR). Vous étendez\moodleform, placez la classe dansclasses/form/(namespace\monplugin\form\), et n’oubliez pas lerequire_once($CFG->libdir . '/formslib.php')— la classe parente n’est pas autoloadée.definition()déclare la structure :$mform = $this->_form, contexte injecté via$this->_customdata(les « props » du formulaire), jamais de lecture directe de$_GET/$_POST. Elle est ré-exécutée à la soumission : les options dynamiques doivent être reconstructibles à l’identique.setType()est obligatoire sur tout champ textuel : c’est le nettoyageclean_param()à la soumission.PARAM_TEXTstrippe le HTML ;PARAM_RAWest réservé auxeditoret aux secrets. Le debug « Did you remember to call setType() » ne doit jamais apparaître.- Validation en trois couches :
addRule()(déclaratif, client + serveur),setType()(nettoyage),validation($data, $files)(règles métier —$dataest un tableau, retour[champ => message], toujours démarrer parparent::validation()). - Le pattern canonique :
is_cancelled()→redirect;get_data()→ traitement +redirect(Post/Redirect/Get contre le double-submit) ; sinonset_data()+display().get_data()retournenullsi non soumis, annulé, sesskey invalide, validation échouée ou bouton no-submit — le seul chemin vers vos données est un chemin sûr (CSRF vérifié, données nettoyées). - Fichiers et éditeur : tout passe par les draft areas.
file_prepare_standard_editor/filemanageravantset_data(),file_postupdate_standard_editor/filemanageraprèsget_data(), avec des options identiques partout ; en création, insérer d’abord l’enregistrement pour disposer de l’itemid. Détails au chapitre 05 (File API). repeat_elements()gère les blocs répétables avec re-render serveur (bouton no-submit) ; pour les formulaires en modal AJAX modernes,\core_form\dynamic_form+ le module JScore_form/modalform(contrat :get_context_for_dynamic_submission,check_access_for_dynamic_submission,process_dynamic_submission,set_data_for_dynamic_submission).- Le markup est rendu par les templates Mustache
core_form/element-*(grille Bootstrap imposée, IDs générés) : stylage fin = surcharge de templates dans le thème, jamais de sélecteurs sur les IDs générés. En AJAX, utilisez fragments oudynamic_form, jamais unrender()brut sans son JS.
Navigation : ← 02 — La base de données · 04 — Output API →