Skip to Content

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

  1. Pourquoi une Form API ?
  2. definition() : le cœur déclaratif du formulaire
  3. Tour complet des éléments addElement()
  4. setType() : obligatoire, pas optionnel
  5. Règles, valeurs par défaut, aide et affichage conditionnel
  6. Validation custom : validation($data, $files)
  7. Le cycle de traitement complet : le pattern canonique
  8. Éditeur, filepicker, filemanager : le trio des draft areas
  9. Repeat elements et formulaires dynamiques (dynamic_form)
  10. Le markup généré et ses limites
  11. 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 du sesskey, 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 via clean_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é dans lib/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 : moodleform est 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 un z.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, et hideIf()/disabledIf() au rendu conditionnel piloté par watch(). 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/, le require_once($CFG->libdir . '/formslib.php') reste obligatoire en tête de fichier. L’autoloader de Moodle charge votre classe entry_form, mais pas sa classe parente \moodleform, qui n’est pas namespacée et vit dans formslib.php. Sans ce require, vous obtenez une erreur fatale « Class “moodleform” not found ». Notez aussi le defined('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 — le require_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 de MoodleQuickForm. Par convention universelle, on l’assigne à une variable locale $mform en 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 dans definition() : 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 fait new entry_form($url, ['courseid' => $courseid]) exactement comme un parent React fait <EntryForm courseId={courseId} />. Ne soyez jamais tenté d’aller lire $_GET directement dans definition() : ce serait comme lire window.location au 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 de select dé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_FLOAT pour un nombre décimal : un utilisateur français tapera 3,14 et clean_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 de setType : 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 : autocomplete avec 'ajax' => 'mon_plugin/mon_selector' est l’analogue direct d’un composant react-select/async : le module AMD référencé doit exposer transport(selector, query, callback) (l’appel réseau, généralement un web service AJAX) et processResults(selector, results) (le mapping vers {value, label}). Le module core_user/form_user_selector du 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->sendnotification n’existe pas si la case est décochée — un if ($data->sendnotification) lèvera un warning PHP 8.3 sur propriété indéfinie. Utilisez empty($data->sendnotification) ou, mieux, advcheckbox qui garantit la présence de la clé. En pratique, advcheckbox devrait être votre choix par défaut, checkbox une 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 hidden n’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’id est passé en hidden). Le setType nettoie 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…). Lire lib/form/autocomplete.php vous 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

ConstanteEffetUsage typique
PARAM_INTCast entierids, timestamps, compteurs
PARAM_FLOATCast flottant (format machine)via l’élément float de préférence
PARAM_ALPHALettres a-z A-Z uniquementcodes internes, valeurs d’enum
PARAM_ALPHANUMEXTLettres, chiffres, _-clés, identifiants techniques
PARAM_TEXTTexte multilingue, tags HTML suppriméstitres, noms, libellés
PARAM_NOTAGSTout HTML strippéproche de TEXT, plus strict
PARAM_EMAILEmail valide ou chaîne videadresses
PARAM_URL / PARAM_LOCALURLURL valide / URL interne au siteliens, redirections
PARAM_RAWAucun nettoyagecontenu d’editor, secrets
PARAM_RAW_TRIMMEDAucun nettoyage sauf trimclé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ègePARAM_TEXT vs PARAM_RAW pour du HTML : PARAM_TEXT supprime les balises HTML. Si vous typez le champ d’un editor en PARAM_TEXT, tout le contenu riche saisi par l’utilisateur (gras, liens, images) sera silencieusement détruit à la soumission. Inversement, typer un simple text en PARAM_RAW « pour être tranquille » stocke du HTML potentiellement hostile. Règle d’or : PARAM_RAW uniquement pour les éléments editor (le HTML y est ensuite assaini à l’affichage par format_text(), vue au chapitre Output API) et les secrets qu’on ne réaffiche jamais.

💡 Pour un dev React : setType() est votre z.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 de get_data() est déjà nettoyée et castée, vous ne manipulez jamais du $_POST brut. 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/disabledIf sont 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 champ hideIf-é 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 de get_data(), la propriété absente peut écraser la colonne avec null selon votre code. Relisez la valeur d’origine en base pour les champs gelés, ou utilisez setConstant() 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 :

  • $data est 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 $errors est non vide, get_data() retournera null et 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 :

  1. Le formulaire n’a pas été soumis (premier affichage en GET).
  2. Le formulaire a été annulé (mais testez is_cancelled() avant, sinon l’annulation tombe dans l’affichage).
  3. Le sesskey soumis ne correspond pas à la session (tentative CSRF ou session expirée).
  4. Une règle addRule serveur a échoué, ou validation() a retourné des erreurs.
  5. 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. Tester get_data() avant is_cancelled() traiterait une annulation comme une soumission dans certains cas limites. Et appeler set_data() avant le bloc get_data() est inutile mais inoffensif ; l’appeler après display() 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 :

  1. file_prepare_standard_editor() avant set_data(), même en création (avec itemid = null) — sinon les images collées dans l’éditeur n’auront pas de brouillon où atterrir.
  2. file_postupdate_standard_editor() après get_data(), avec un id d’enregistrement existant — d’où le pattern « insérer vide puis mettre à jour » en création.
  3. Les mêmes $editoroptions partout. Un maxbytes diffé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 $data contient à la fois intro (le HTML final avec @@PLUGINFILE@@) et l’ancien intro_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 $data ailleurs (événement, cache, web service), pensez à retirer les champs _editor/_filemanager. Et à l’affichage, n’oubliez pas file_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.php et des stored_file est 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 dans args: 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_form inline dans la page (module core_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-9 est 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 : utilisez data-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 :

  1. Attributs et classes custom : addElement('text', 'x', $label, ['class' => 'local-foo-highlight']) — le plus simple, souvent suffisant.
  2. Surcharge de templates dans un thème : copiez lib/form/templates/element-template.mustache vers theme/montheme/templates/core_form/element-template.mustache et 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.
  3. Élément de formulaire custom : une classe étendant MoodleQuickForm_element (ou un élément existant), enregistrée via MoodleQuickForm::registerElementType(), avec son propre template. C’est ainsi que sont faits autocomplete, course, etc.
  4. Renderer : le rendu passe par core_renderer et 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’un echo $this->render()), pratique pour l’inclure dans un template ou une réponse.
  • L’API Fragment ($OUTPUT + callback component_output_fragment_xxx() dans lib.php, appelée côté JS par core/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 simple render() 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 et dynamic_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

  • moodleform est un wrapper au-dessus d’un fork embarqué de HTML_QuickForm (PEAR). Vous étendez \moodleform, placez la classe dans classes/form/ (namespace \monplugin\form\), et n’oubliez pas le require_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 nettoyage clean_param() à la soumission. PARAM_TEXT strippe le HTML ; PARAM_RAW est réservé aux editor et 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 — $data est un tableau, retour [champ => message], toujours démarrer par parent::validation()).
  • Le pattern canonique : is_cancelled()redirect ; get_data() → traitement + redirect (Post/Redirect/Get contre le double-submit) ; sinon set_data() + display(). get_data() retourne null si 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/filemanager avant set_data(), file_postupdate_standard_editor/filemanager après get_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 JS core_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 ou dynamic_form, jamais un render() brut sans son JS.

Navigation : ← 02 — La base de données · 04 — Output API →