04 — Output API : renderers, templates Mustache, $PAGE, navigation
Chapitre précédent : 03 — Form API · Chapitre suivant : 05 — Fichiers, strings, config
Ce chapitre couvre l’intégralité de la couche de présentation de Moodle 5.2 : comment une page HTML est réellement produite, du contrôleur PHP jusqu’au template Mustache, en passant par les objets globaux $PAGE et $OUTPUT, la navigation, les notifications, la pagination et les tables SQL.
Mini-sommaire
- Vue d’ensemble de la couche présentation
$OUTPUTetcore_renderer- Renderables +
templatable - Mustache côté PHP : syntaxe et helpers
$PAGE(moodle_page) en détail- Notifications
html_writer: legacy mais omniprésentmoodle_url- Navigation API
- Pagination :
paging_bar - Tables :
flexible_tableettable_sql - Résumé — points clés
1. Vue d’ensemble de la couche présentation
Moodle n’a jamais eu de « framework MVC » au sens strict. Sa couche de présentation est le résultat de vingt ans d’évolution, et vous croiserez les trois âges du rendu HTML dans le même code source — parfois dans le même fichier. Comprendre ces trois âges est indispensable pour lire le code du cœur et savoir quel style adopter dans votre code.
1.1 Les trois âges du rendu Moodle
Âge 1 — le echo de HTML brut (années 2000, à proscrire). Le PHP « à l’ancienne » : on mélange logique et balisage.
// ❌ Style préhistorique — vous le verrez encore dans de très vieux plugins.
echo '<div class="ma-boite"><h2>' . $titre . '</h2>';
foreach ($items as $item) {
echo '<li><a href="view.php?id=' . $item->id . '">' . $item->name . '</a></li>';
}
echo '</div>';Aucun échappement, URLs concaténées à la main, impossible à surcharger par un thème, intestable. Ne produisez jamais ce code.
Âge 2 — html_writer (Moodle 2.0, 2010). Une classe utilitaire qui génère des balises proprement échappées :
// ⚠️ Style « legacy toléré » — encore massivement présent dans le cœur.
echo html_writer::div(
html_writer::tag('h2', s($titre)),
'ma-boite'
);C’est mieux (échappement centralisé, pas de HTML dans les chaînes), mais la structure du HTML reste enfouie dans le PHP : un thème ne peut pas la modifier sans réécrire le PHP.
Âge 3 — renderables + templates Mustache (Moodle 2.9+, 2015, style moderne). La logique produit un objet de données pur (le renderable), et un template Mustache — un simple fichier texte, sans logique — le transforme en HTML :
// ✅ Style moderne : les données d'un côté...
$data = [
'title' => format_string($titre),
'items' => $itemsexportes, // tableau de données « bêtes »
'hasitems' => !empty($itemsexportes),
];
echo $OUTPUT->render_from_template('local_foo/item_list', $data);{{! ...le HTML de l'autre (templates/item_list.mustache). }}
<div class="ma-boite">
<h2>{{title}}</h2>
{{#hasitems}}
<ul>
{{#items}}<li><a href="{{url}}">{{name}}</a></li>{{/items}}
</ul>
{{/hasitems}}
</div>Les avantages sont énormes : le thème peut surcharger le template sans toucher au PHP, le même template peut être rendu côté JavaScript (module core/templates), et l’échappement est automatique.
💡 Pour un dev React : la comparaison qui fait tilt : un template Mustache, c’est un composant JSX sans aucune logique. Pas de
if, pas demapavec transformation, pas de calcul : uniquement de l’interpolation{{var}}et des sections conditionnelles/itératives sur des données déjà préparées. C’est la philosophie logic-less : là où JSX vous laisse écrireitems.filter(...).map(i => <li>{i.name.toUpperCase()}</li>), Mustache vous force à faire ce travail en PHP dansexport_for_template(). Le renderable joue le rôle des props, etexport_for_template()est votre couche de mapping props → view-model.
1.2 Où vit quoi ?
| Élément | Emplacement | Rôle |
|---|---|---|
$OUTPUT | global, instance de \core\output\core_renderer (ou sa surcharge par le thème) | Rendu des composants du cœur : header, footer, boutons, icônes… |
$PAGE | global, instance de moodle_page | Métadonnées de la page : URL, contexte, titre, layout, JS/CSS requis, navigation |
| Renderer d’un plugin | local_foo/classes/output/renderer.php → classe \local_foo\output\renderer | Méthodes render_xxx() spécifiques au plugin |
| Renderables d’un plugin | local_foo/classes/output/*.php | Objets de données implémentant renderable / templatable |
| Templates d’un plugin | local_foo/templates/*.mustache | Le HTML |
| Templates du cœur | lib/templates/, course/templates/… | Composants génériques (core/notification, core/paging_bar…) |
| Surcharges du thème | theme/montheme/templates/local_foo/*.mustache | Un thème peut remplacer n’importe quel template |
📚 Aller plus loin : la page de référence de l’Output API sur moodledev.io/docs/apis/subsystems/output et le guide des templates moodledev.io/docs/guides/templates sont les deux documents à garder ouverts en permanence pendant ce chapitre.
1.3 Évolution Moodle 4.5 / 5.x : les classes output sont désormais namespacées
Historiquement, toutes les classes de sortie (html_writer, pix_icon, single_button, action_link, moodle_url…) vivaient dans l’espace de noms global, définies dans lib/outputcomponents.php et lib/weblib.php. Depuis Moodle 4.5, elles ont été déplacées dans des espaces de noms :
html_writer→\core\output\html_writerpix_icon→\core\output\pix_iconsingle_button→\core\output\single_buttonaction_link→\core\output\action_linkhelp_icon→\core\output\help_iconrenderer_base,plugin_renderer_base,core_renderer→\core\output\...paging_bar→\core\output\paging_barmoodle_url→\core\url
Des alias de compatibilité existent (le code qui utilise new moodle_url(...) fonctionne toujours), mais dans du code neuf ciblant Moodle 5.2, importez les classes namespacées :
use core\output\html_writer;
use core\output\pix_icon;
use core\output\single_button;
use core\url; // le nouveau nom de moodle_url.Dans ce chapitre, nous utiliserons souvent les noms historiques (moodle_url, html_writer) parce que c’est ce que vous verrez dans 95 % du code existant, mais retenez que le nom canonique en 5.2 est le nom namespacé.
⚠️ Piège : ne confondez pas
\core\output\notification(le composant visuel, la boîte colorée Bootstrap) et\core\notification(l’API de session qui empile des messages à afficher après une redirection, voir section 6). Les deux existent, les deux s’appellent « notification », et elles collaborent — mais ce sont deux classes différentes.
2. $OUTPUT et core_renderer
$OUTPUT est l’objet global de rendu. Sa classe réelle dépend du thème actif : par défaut c’est \core\output\core_renderer, mais le thème Boost fournit \theme_boost\output\core_renderer qui en hérite et surcharge certaines méthodes. C’est le mécanisme par lequel un thème change l’apparence de tout Moodle sans toucher au cœur (nous y reviendrons dans la partie thèmes).
2.1 header() et footer() : ce qu’ils font vraiment
Toute page Moodle « standard » suit ce squelette :
// Squelette canonique d'une page Moodle.
require(__DIR__ . '/../../config.php'); // Bootstrap : $CFG, $DB, $PAGE, $OUTPUT, $USER...
require_login(); // Authentification (et inscription au cours le cas échéant).
$PAGE->set_url(new moodle_url('/local/foo/index.php'));
$PAGE->set_context(context_system::instance());
$PAGE->set_title(get_string('pluginname', 'local_foo'));
$PAGE->set_heading(get_string('pluginname', 'local_foo'));
echo $OUTPUT->header(); // ← Rien ne doit être affiché AVANT cette ligne.
echo $OUTPUT->heading(get_string('welcome', 'local_foo'));
// ... le contenu de la page ...
echo $OUTPUT->footer(); // ← Et rien après celle-ci.$OUTPUT->header() ne se contente pas d’ouvrir <html><head>.... Il déclenche une cascade :
- Verrouillage de
$PAGE: plus aucunset_url(),set_pagelayout(),requires->css()n’est accepté après. - Résolution du layout : le
pagelayoutdemandé (standard,incourse,admin…) est cherché dans leconfig.phpdu thème, qui pointe vers un fichier de layout (theme/boost/layout/drawers.phppar exemple). - Rendu du
<head>: titre, balises meta, CSS compilé du thème, appels JS enregistrés via$PAGE->requires. - Rendu de la structure de page : barre de navigation primaire, fil d’ariane, blocs des régions latérales, en-tête de cours, notifications en attente… Le layout du thème est lui-même un template Mustache (
theme/boost/templates/drawers.mustache) alimenté par un gros tableau de données. - Le HTML est renvoyé jusqu’au début de la zone de contenu principal ; votre
echode contenu s’insère là, etfooter()referme tout (fin du main, footer du thème, chargement différé du JavaScript,</body></html>).
💡 Pour un dev React :
$OUTPUT->header()+$OUTPUT->footer()≈ lelayout.tsxde Next.js App Router combiné au head manager. Votre page est le{children}inséré au milieu. Et comme dans Next.js où les métadonnées (export const metadata) doivent être déclarées avant le rendu, tout ce qui touche$PAGEdoit être fait avantheader()— après, c’est trop tard, le<head>est déjà parti dans le buffer de sortie.
2.2 Les méthodes de rendu les plus utiles
// --- Titres et boîtes -------------------------------------------------------
echo $OUTPUT->heading('Mon titre'); // <h2>Mon titre</h2>
echo $OUTPUT->heading('Sous-titre', 3, 'ma-classe'); // niveau + classes CSS.
echo $OUTPUT->box('Contenu encadré', 'generalbox mt-3');
// Variante ouverte/fermée pour englober un gros bloc :
echo $OUTPUT->box_start('generalbox');
// ... contenu ...
echo $OUTPUT->box_end();
// --- Notification inline (rendu immédiat, PAS stockée en session) ----------
echo $OUTPUT->notification('Opération réussie.', \core\output\notification::NOTIFY_SUCCESS);
echo $OUTPUT->notification('Attention !', \core\output\notification::NOTIFY_WARNING);
// --- Images et icônes -------------------------------------------------------
// URL d'une image du plugin (fichier local_foo/pix/logo.png ou .svg) :
$logourl = $OUTPUT->image_url('logo', 'local_foo');
echo html_writer::img($logourl, get_string('logoalt', 'local_foo'));
// Icône « pix » : résolue par le thème (depuis Moodle 4.x : FontAwesome 6
// via le mapping d'icônes, plus des fichiers image par défaut).
echo $OUTPUT->pix_icon('t/edit', get_string('edit')); // icône du cœur.
echo $OUTPUT->pix_icon('icon', get_string('pluginname', 'local_foo'), 'local_foo');
// --- Boutons ----------------------------------------------------------------
// single_button : un mini-formulaire <form> avec un seul bouton (POST par défaut).
echo $OUTPUT->single_button(
new moodle_url('/local/foo/delete.php', ['id' => $id, 'sesskey' => sesskey()]),
get_string('delete'),
'post'
);
// Bouton "Continuer" standard :
echo $OUTPUT->continue_button(new moodle_url('/local/foo/index.php'));
// Écran de confirmation complet (message + boutons Oui/Non) :
echo $OUTPUT->confirm(
get_string('confirmdelete', 'local_foo', $item->name),
new moodle_url('/local/foo/delete.php', ['id' => $id, 'confirm' => 1, 'sesskey' => sesskey()]),
new moodle_url('/local/foo/index.php') // URL d'annulation.
);
// --- Liens d'action et aide -------------------------------------------------
// action_link : un <a> qui peut porter des actions JS (confirmation, etc.).
echo $OUTPUT->action_link(
new moodle_url('/local/foo/edit.php', ['id' => $id]),
get_string('edit'),
null,
['class' => 'btn btn-secondary']
);
// help_icon : le petit "?" qui ouvre l'aide contextuelle. La chaîne
// 'perimeter' ET 'perimeter_help' doivent exister dans le fichier de langue.
echo $OUTPUT->help_icon('perimeter', 'local_foo');
// --- Le point d'entrée générique -------------------------------------------
echo $OUTPUT->render($monrenderable); // voir section 3.
echo $OUTPUT->render_from_template('local_foo/card', $data); // rendu direct.2.3 Comment le thème surcharge le renderer (teaser)
Quand Moodle doit instancier $OUTPUT, il ne fait pas new core_renderer() aveuglément : il demande au thème actif s’il fournit sa propre classe. Boost déclare \theme_boost\output\core_renderer extends \core\output\core_renderer et y redéfinit, par exemple, le rendu du menu utilisateur. Le même mécanisme vaut pour les renderers de plugins : un thème peut définir \theme_montheme\output\mod_quiz_renderer pour changer le rendu du quiz. La chaîne de résolution complète (thème enfant → thème parent → cœur) sera détaillée dans la partie consacrée aux thèmes ; retenez simplement que toute méthode de renderer est surchargeable par le thème, c’est le contrat central de l’Output API.
⚠️ Piège :
$OUTPUTn’est pleinement utilisable qu’une fois le thème initialisé, ce qui se produit au premier accès après que$PAGE->set_context()a été appelé. Si vous appelez$OUTPUT->image_url()très tôt dans un script (avantrequire_login()/set_context()), vous risquez de figer le thème prématurément avec un avertissement de debug. Ordre sain :config.php→require_login()→ configuration de$PAGE→ seulement ensuite$OUTPUT.
3. Renderables + templatable
C’est le cœur du style moderne. Deux interfaces, toutes deux dans \core\output :
renderable: une interface vide, un simple marqueur. Elle dit : « cet objet représente quelque chose d’affichable, passez-le à$OUTPUT->render()».templatable: ajoute une seule méthode,export_for_template(renderer_base $output), qui doit retourner les données prêtes pour un template Mustache (unstdClassou un tableau associatif de scalaires, tableaux et objets simples).
3.1 Exemple complet : la page d’index d’un plugin local_foo
Le renderable — local/foo/classes/output/index_page.php :
<?php
namespace local_foo\output;
use core\output\renderable;
use core\output\renderer_base;
use core\output\templatable;
use moodle_url;
/**
* Page d'index du plugin local_foo.
*
* Cet objet transporte les données métier ; export_for_template() les
* aplatit en « dumb data » consommables par Mustache.
*
* @package local_foo
* @copyright 2026 Vous
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class index_page implements renderable, templatable {
/** @var array Liste d'enregistrements métier (issus de $DB). */
protected array $items;
/** @var bool L'utilisateur courant peut-il gérer les items ? */
protected bool $canmanage;
/** @var \context Le contexte de la page (pour format_text). */
protected \context $context;
public function __construct(array $items, bool $canmanage, \context $context) {
$this->items = $items;
$this->canmanage = $canmanage;
$this->context = $context;
}
/**
* Exporte les données pour le template Mustache.
*
* RÈGLE D'OR : tout est précalculé ici. Le template ne doit contenir
* AUCUNE logique — pas de comparaison, pas de formatage, pas d'URL
* à assembler. Booléens prêts, URLs en string, texte déjà formaté.
*
* @param renderer_base $output Le renderer (utile pour image_url, etc.).
* @return \stdClass
*/
public function export_for_template(renderer_base $output): \stdClass {
$data = new \stdClass();
$data->canmanage = $this->canmanage; // booléen prêt.
$data->addurl = (new moodle_url('/local/foo/edit.php'))->out(false);
$data->items = [];
foreach ($this->items as $item) {
$data->items[] = [
// format_string pour un titre (filtres + nettoyage) :
'name' => format_string($item->name, true, ['context' => $this->context]),
// format_text pour un contenu riche : le HTML est produit ICI,
// le template l'insérera avec {{{description}}} (triple moustache).
'description' => format_text($item->description, $item->descriptionformat,
['context' => $this->context]),
'viewurl' => (new moodle_url('/local/foo/view.php', ['id' => $item->id]))->out(false),
'editurl' => (new moodle_url('/local/foo/edit.php', ['id' => $item->id]))->out(false),
// Booléen précalculé plutôt qu'une comparaison dans Mustache :
'isvisible' => (bool) $item->visible,
'timecreated' => userdate($item->timecreated), // date déjà formatée.
];
}
// Mustache ne sait pas tester count() : on fournit le booléen.
$data->hasitems = !empty($data->items);
return $data;
}
}Le template — local/foo/templates/index_page.mustache :
{{!
This file is part of Moodle - http://moodle.org/
(en-tête de licence GPL habituel...)
}}
{{!
@template local_foo/index_page
Page d'index du plugin local_foo.
Classes required for JS: none.
Data attributes required for JS: none.
Context variables required for this template:
* hasitems - bool, y a-t-il au moins un item ?
* items - array d'objets {name, description, viewurl, editurl, isvisible, timecreated}
* canmanage - bool, afficher les contrôles d'édition ?
* addurl - string, URL du formulaire de création.
Example context (json):
{
"hasitems": true,
"canmanage": true,
"addurl": "https://example.com/local/foo/edit.php",
"items": [
{
"name": "Premier item",
"description": "<p>Une <strong>description</strong> riche.</p>",
"viewurl": "https://example.com/local/foo/view.php?id=1",
"editurl": "https://example.com/local/foo/edit.php?id=1",
"isvisible": true,
"timecreated": "7 juillet 2026, 10:15"
}
]
}
}}
<div class="local-foo-index">
{{#canmanage}}
<div class="mb-3">
<a href="{{addurl}}" class="btn btn-primary">
{{#pix}} t/add, core {{/pix}} {{#str}} additem, local_foo {{/str}}
</a>
</div>
{{/canmanage}}
{{#hasitems}}
<ul class="list-group">
{{#items}}
<li class="list-group-item {{^isvisible}}dimmed_text{{/isvisible}}">
<a href="{{viewurl}}"><strong>{{name}}</strong></a>
<span class="text-muted small ms-2">{{timecreated}}</span>
{{#canmanage}}
<a href="{{editurl}}" class="ms-2" title="{{#str}} edit, core {{/str}}">
{{#pix}} t/edit, core, {{#str}} edit, core {{/str}} {{/pix}}
</a>
{{/canmanage}}
{{! Triple moustache : le HTML a été nettoyé par format_text en PHP. }}
<div class="mt-1">{{{description}}}</div>
</li>
{{/items}}
</ul>
{{/hasitems}}
{{^hasitems}}
{{< core/notification_info }}
{{$message}}{{#str}} noitems, local_foo {{/str}}{{/message}}
{{/ core/notification_info }}
{{/hasitems}}
</div>La page — local/foo/index.php :
<?php
require(__DIR__ . '/../../config.php');
require_login();
$context = context_system::instance();
require_capability('local/foo:view', $context);
$PAGE->set_url(new moodle_url('/local/foo/index.php'));
$PAGE->set_context($context);
$PAGE->set_title(get_string('pluginname', 'local_foo'));
$PAGE->set_heading(get_string('pluginname', 'local_foo'));
$items = $DB->get_records('local_foo_items', null, 'timecreated DESC');
$canmanage = has_capability('local/foo:manage', $context);
$page = new \local_foo\output\index_page($items, $canmanage, $context);
echo $OUTPUT->header();
echo $OUTPUT->render($page); // ← la magie opère ici.
echo $OUTPUT->footer();3.2 Que fait $OUTPUT->render($page) exactement ?
La convention de résolution est la suivante :
- Moodle déduit le nom court de la classe :
\local_foo\output\index_page→index_page. - Il cherche une méthode
render_index_page()— d’abord sur le renderer du plugin (\local_foo\output\renderer, éventuellement surchargé par le thème), puis sur le renderer du cœur. - Si une telle méthode existe, elle est appelée avec le renderable ; c’est elle qui décide comment rendre.
- Sinon, si l’objet est
templatable, Moodle applique le rendu par défaut :export_for_template()puis le templatelocal_foo/index_page(déduit du namespace + nom de classe).
3.3 Le renderer de plugin — et quand s’en passer
Le renderer de plugin, local/foo/classes/output/renderer.php :
<?php
namespace local_foo\output;
use core\output\plugin_renderer_base;
/**
* Renderer du plugin local_foo.
*/
class renderer extends plugin_renderer_base {
/**
* Rendu de la page d'index (appelée automatiquement par $OUTPUT->render()
* grâce à la convention de nommage render_ + nom de classe).
*
* @param index_page $page
* @return string HTML
*/
protected function render_index_page(index_page $page): string {
$data = $page->export_for_template($this);
return $this->render_from_template('local_foo/index_page', $data);
}
}Et pour l’utiliser explicitement :
$renderer = $PAGE->get_renderer('local_foo');
echo $renderer->render($page);Faut-il encore créer un renderer ? Honnêtement : de moins en moins. Le renderer était le point d’extension avant Mustache ; aujourd’hui, le point d’extension privilégié est le template lui-même (surchargeable par le thème). Si votre méthode render_xxx() se contente de faire export_for_template() + render_from_template(), elle n’apporte rien : appelez directement $OUTPUT->render($renderable) (qui fait exactement cela par défaut), voire $OUTPUT->render_from_template('local_foo/index_page', $data) avec des données construites à la main. Créez un renderer uniquement si vous voulez offrir aux thèmes un point de surcharge PHP (logique de rendu conditionnelle complexe), ce qui est rare.
💡 Pour un dev React : le renderer est un composant de présentation (dumb component) : il reçoit des props (le renderable) et retourne du markup, sans jamais toucher à la base de données ni à l’état global. Le renderable +
export_for_template()correspond à votre couche container/hook qui prépare les props. Et comme en React où l’on a cessé d’écrire desclass componentsquand les fonctions suffisaient, on cesse d’écrire des renderers quandrender_from_template()suffit.
⚠️ Piège :
export_for_template()doit retourner des données sérialisables en JSON — le même contrat que des props sérialisées. Pas d’objetmoodle_url(appelez->out(false)), pas d’objetlang_stringnon converti, pas de closure. La raison profonde : le même template et le même contexte peuvent être rendus côté JavaScript, où seul du JSON circule. Si vous glissez un objet complexe, ça « marchera » parfois en PHP puis explosera côté JS ou dans la component library.
4. Mustache côté PHP : syntaxe et helpers
Moodle embarque une implémentation PHP de Mustache (et une implémentation JS pour le rendu côté client). Passons la syntaxe en revue, puis les helpers spécifiques à Moodle.
4.1 La syntaxe Mustache complète
{{! Ceci est un commentaire — jamais rendu, n'apparaît pas dans le HTML. }}
{{! 1. Interpolation ÉCHAPPÉE (défaut) : les entités HTML sont encodées. }}
<p>{{username}}</p>
{{! Si username = "<script>", le rendu est <script> — sûr. }}
{{! 2. Interpolation BRUTE (triple moustache) : le HTML passe tel quel. }}
<div>{{{descriptionhtml}}}</div>
{{! ⚠️ À réserver au HTML déjà nettoyé par format_text() côté PHP. }}
{{! 3. Sections : conditionnelle si la valeur est un booléen... }}
{{#canedit}}
<a href="{{editurl}}">Modifier</a>
{{/canedit}}
{{! ...ou itérative si la valeur est un tableau (le contexte descend dans l'item). }}
{{#items}}
<li>{{name}}</li>
{{/items}}
{{! 4. Section inversée : rendue si la valeur est false, vide ou tableau vide. }}
{{^items}}
<p>Aucun élément.</p>
{{/items}}
{{! 5. Partial : inclusion d'un autre template (composant réutilisable). }}
{{> core/loading }}
{{> local_foo/item_card }}
{{! 6. Héritage de template (pragma Mustache "blocks") :
{{< parent }} inclut un template parent, {{$bloc}} remplace ses blocs. }}
{{< core/modal }}
{{$title}}Mon titre de modale{{/title}}
{{$body}}Le corps de ma modale{{/body}}
{{/ core/modal }}⚠️ Piège : la triple moustache
{{{var}}}est la porte d’entrée n° 1 des failles XSS dans les plugins Moodle. La règle :{{var}}par défaut, partout.{{{var}}}uniquement pour du HTML produit parformat_text()(qui nettoie via HTML Purifier) ou par un autre rendu de template. Si vous écrivez{{{name}}}« parce que les apostrophes s’affichent bizarrement », vous avez un problème d’échappement en amont (double échappement PHP), pas un besoin de triple moustache.
4.2 Les helpers Moodle
Moodle enrichit Mustache de helpers indispensables. Ils fonctionnent en PHP et en JS.
{{! --- {{#str}} : chaîne de langue. Syntaxe : identifiant, composant, paramètre $a. }}
<h3>{{#str}} pluginname, local_foo {{/str}}</h3>
{{! Avec un $a scalaire : }}
<p>{{#str}} welcomeuser, local_foo, {{username}} {{/str}}</p>
{{! Avec un $a complexe, on passe du JSON — les valeurs peuvent venir du contexte : }}
<p>{{#str}} itemsummary, local_foo, { "name": {{#quote}} {{name}} {{/quote}}, "count": {{count}} } {{/str}}</p>
{{! --- {{#cleanstr}} : comme {{#str}} mais le résultat passe par clean_string().
À utiliser quand la chaîne atterrit dans un attribut HTML. }}
<input type="image" alt="{{#cleanstr}} delete, core {{/cleanstr}}" ...>
{{! --- {{#quote}} : entoure la valeur de guillemets JSON en échappant proprement.
Sert essentiellement à construire le $a JSON de {{#str}} ci-dessus. }}
{{! --- {{#pix}} : icône. Syntaxe : clé, composant, texte alternatif. }}
{{#pix}} t/edit, core, {{#str}} edit, core {{/str}} {{/pix}}
{{#pix}} icon, local_foo {{/pix}}
{{! --- {{#shortentext}} : tronque proprement (mots entiers, ellipse).
Syntaxe : longueur, texte. }}
<p>{{#shortentext}} 80, {{description}} {{/shortentext}}</p>
{{! --- {{#userdate}} : formate un timestamp UNIX selon le fuseau de l'utilisateur.
Syntaxe : timestamp, format strftime (souvent une chaîne core_langconfig). }}
<span>{{#userdate}} {{timecreated}}, {{#str}} strftimedatetime, core_langconfig {{/str}} {{/userdate}}</span>
{{! --- {{#js}} : enregistre du JS exécuté après le rendu du template.
À réserver aux initialisations minimales — préférez js_call_amd côté PHP. }}
{{#js}}
require(['local_foo/controller'], function(Controller) {
Controller.init('{{uniqid}}');
});
{{/js}}Deux helpers utilitaires du contexte méritent mention : {{uniqid}} (un identifiant unique par rendu, injecté automatiquement, parfait pour lier un template à son JS via un id) et la possibilité d’imbriquer les helpers comme vu ci-dessus ({{#pix}} contenant {{#str}}).
⚠️ Piège : les helpers ne peuvent pas être utilisés à l’intérieur des données : si votre
export_for_template()renvoie la chaîne"{{#str}} hack, core {{/str}}"saisie par un utilisateur, Moodle la neutralise (protection contre l’injection de helpers). Ne comptez donc jamais sur des helpers « dynamiques » venant du contexte — et réciproquement, ne paniquez pas si des{{ }}saisis par un utilisateur s’affichent littéralement : c’est voulu.
4.3 L’en-tête de template obligatoire : @template et « Example context »
Vous avez remarqué le gros bloc de commentaire en tête de index_page.mustache. Il n’est pas décoratif, il est obligatoire (vérifié par les outils de CI Moodle) :
@template local_foo/index_page: identifie le template.- La liste des variables de contexte requises : la documentation du « contrat de props ».
Example context (json): un contexte d’exemple valide en JSON. Il sert à deux choses concrètes :- la component library (
/admin/tool/componentlibrary) rend le template avec ce contexte pour l’afficher dans le catalogue de composants ; - les tests Behat et l’outil de vérification des templates l’utilisent pour vérifier que le template compile.
- la component library (
Sans exemple de contexte valide, votre template sera rejeté par grunt et par la revue de code moodle.org.
4.4 Résolution des templates et surcharge par le thème
Quand vous demandez local_foo/index_page, Moodle cherche dans l’ordre :
theme/monthemeenfant/templates/local_foo/index_page.mustache(thème actif)theme/boost/templates/local_foo/index_page.mustache(thèmes parents, en remontant)local/foo/templates/index_page.mustache(le plugin lui-même)
C’est ainsi qu’un thème peut redessiner, par exemple, la carte de cours de core_course en créant theme/montheme/templates/core_course/coursecard.mustache — zéro PHP. Les templates compilés sont mis en cache ($CFG->cachedir), pensez à purger les caches (php admin/cli/purge_caches.php) quand un template modifié ne semble pas pris en compte — ou activez $CFG->themedesignermode en développement.
4.5 Rendu Mustache côté JavaScript (mention)
Le même template peut être rendu dans le navigateur via le module AMD core/templates :
import Templates from 'core/templates';
const {html, js} = await Templates.renderForPromise('local_foo/item_card', context);
Templates.replaceNodeContents(container, html, js);Le template est récupéré via un appel AJAX (core_output_load_template), mis en cache dans le navigateur, et rendu par Mustache.js. Les helpers {{#str}}, {{#pix}}… fonctionnent aussi côté JS. Nous détaillerons ce flux dans la partie JavaScript de cette documentation.
📚 Aller plus loin : moodledev.io/docs/guides/templates pour la référence complète des helpers, et la component library de votre propre instance (
Administration du site → Développement → UI Component library) pour explorer interactivement tous les templates du cœur avec leurs contextes d’exemple.
5. $PAGE (moodle_page) en détail
$PAGE est l’objet de configuration de la page courante. Presque toutes les pages commencent par une salve d’appels $PAGE->set_*(), et l’ordre compte.
5.1 Les setters essentiels
require(__DIR__ . '/../../config.php');
$id = required_param('id', PARAM_INT);
$action = optional_param('action', '', PARAM_ALPHA);
// --- set_url : OBLIGATOIRE, et AVEC les paramètres ---------------------------
// C'est l'URL "canonique" de la page. Elle sert : au fil d'ariane, au bouton
// "réessayer" après erreur, aux redirections de login, aux logs, au bouton
// d'activation de l'édition... Si vous omettez les params, un F5 après login
// retombera sur la page SANS id → erreur.
$PAGE->set_url(new moodle_url('/local/foo/view.php', ['id' => $id]));
// --- set_context : le contexte de la page (permissions, filtres, fichiers) --
$PAGE->set_context(context_system::instance());
// Pour une page DE COURS, préférez set_course (qui règle aussi le contexte) :
$course = get_course($courseid);
require_login($course); // require_login($course) appelle set_course pour vous.
// $PAGE->set_course($course); // ...sinon manuellement.
// Pour une page DE MODULE (activité), set_cm règle cm + cours + contexte :
[$course, $cm] = get_course_and_cm_from_cmid($cmid, 'quiz');
require_login($course, false, $cm); // règle $PAGE->cm, course, context d'un coup.
// $PAGE->set_cm($cm, $course);
// --- Titre (onglet navigateur) et heading (gros titre dans l'en-tête) -------
$PAGE->set_title(get_string('viewitem', 'local_foo')); // <title> — Moodle 4.x+
// y ajoute automatiquement
// " | Nom du site".
$PAGE->set_heading($course->fullname ?? format_string($SITE->fullname));
// --- Layout : quel gabarit du thème utiliser --------------------------------
$PAGE->set_pagelayout('standard');5.2 Les layouts disponibles
Chaque thème déclare ses layouts dans son config.php. Les identifiants standard (que tout thème doit gérer) :
| Layout | Usage |
|---|---|
base | Minimal, sans fioritures |
standard | Page générique avec navigation complète |
course | Page principale d’un cours |
incourse | Page dans un cours (la plus courante pour les activités) |
frontpage | Page d’accueil du site |
admin | Pages d’administration |
mydashboard | Tableau de bord |
mypublic | Profil public |
login | Page de connexion |
popup | Fenêtre popup : pas de navigation, pas de blocs |
frametop | Sans footer ni blocs (anciens usages en frames) |
embedded | Contenu embarqué (iframe, lecteur…) : strict minimum |
maintenance | Maintenance/installation : aucune dépendance BDD |
print | Optimisé impression |
redirect | Page de redirection intermédiaire |
report | Rapports : largeur maximale |
secure | Navigation sécurisée (safe exam browser) |
5.3 Navigation secondaire et fil d’ariane manuel
// Forcer l'affichage (ou le masquage) de la navigation secondaire (les onglets
// sous l'en-tête, introduits par Boost en 4.0) :
$PAGE->set_secondary_navigation(false); // pas d'onglets sur cette page.
$PAGE->set_secondary_active_tab('coursehome'); // onglet actif explicite.
// Fil d'ariane : la navbar se construit normalement toute seule à partir de
// l'arbre de navigation, mais on peut y ajouter des étapes manuellement :
$PAGE->navbar->add(get_string('pluginname', 'local_foo'),
new moodle_url('/local/foo/index.php'));
$PAGE->navbar->add(format_string($item->name)); // dernier maillon : pas de lien.5.4 $PAGE->requires : le pont vers JavaScript et CSS
$PAGE->requires (un objet page_requirements_manager) collecte tout ce qui doit être injecté dans la page : modules AMD, CSS supplémentaire, chaînes de langue pour JS…
// --- LA méthode moderne : appeler la fonction init d'un module AMD/ESM ------
// Charge local/foo/amd/build/controller.min.js et appelle sa fonction init()
// avec les arguments donnés (sérialisés en JSON).
$PAGE->requires->js_call_amd('local_foo/controller', 'init', [
['itemid' => $id, 'contextid' => $context->id],
]);
// --- CSS additionnel (rare : le CSS d'un plugin vit dans styles.css,
// compilé avec le thème ; ceci est pour les cas dynamiques) --------------
$PAGE->requires->css('/local/foo/extra.css'); // AVANT $OUTPUT->header() !
// --- Exposer des chaînes de langue au JavaScript -----------------------------
// (côté JS moderne, préférez le module 'core/str' qui les charge en AJAX,
// mais ces méthodes restent utiles pour éviter un aller-retour)
$PAGE->requires->string_for_js('confirmdelete', 'local_foo');
$PAGE->requires->strings_for_js(['save', 'cancel'], 'core');
// → accessibles côté JS via M.util.get_string('confirmdelete', 'local_foo').
// --- Divers -------------------------------------------------------------------
$PAGE->requires->data_for_js('mavariable', $donnees); // expose M.cfg-like (legacy).js_call_amd() est le point de jonction officiel entre une page PHP et votre JavaScript : la page rend le HTML (via Mustache), et js_call_amd démarre le contrôleur JS qui l’anime. Le chapitre JavaScript détaillera l’écosystème AMD/ESM, core/ajax et les web services.
5.5 Autres membres utiles
$PAGE->theme; // objet theme_config du thème actif ($PAGE->theme->name).
$PAGE->context; // le contexte réglé par set_context/set_cm.
$PAGE->course; // le cours courant (ou le pseudo-cours "site").
$PAGE->cm; // le course_module courant (cm_info) ou null.
$PAGE->user_is_editing(); // true si l'utilisateur a activé le mode édition
// (ET a la permission) — pour afficher les contrôles
// d'édition conditionnellement.
$PAGE->add_body_class('local-foo-page'); // classe CSS sur <body>.
$PAGE->set_docs_path('local/foo/view'); // lien "Documentation Moodle" du footer.5.6 L’ordre des appels — la source n° 1 de bugs de débutant
Règle absolue : toute la configuration de $PAGE doit précéder echo $OUTPUT->header(). Au moment où header() s’exécute, le <head> est généré et la page est verrouillée. Les erreurs typiques :
echo $OUTPUT->header();
$PAGE->set_title('Trop tard'); // ❌ exception coding_exception.
$PAGE->requires->css('/local/foo/extra.css'); // ❌ exception : head déjà rendu.
$PAGE->requires->js_call_amd('local_foo/x', 'init'); // ✅ OK ! Le JS est injecté
// dans le footer, donc accepté
// même après header().⚠️ Piège :
$PAGE->set_url()avant tout traitement susceptible d’échouer ou de rediriger. Si une exception survient avantset_url(), Moodle ne sait pas construire la page d’erreur correctement et vous obtenez des messages de debug cryptiques (« Coding problem: this page does not set $PAGE->url »). Premier réflexe dans tout nouveau fichier de page :set_url,set_context, ensuite la logique.
💡 Pour un dev React : pensez
$PAGEcomme la fusion du layout et du head manager de Next.js.set_title/set_heading≈metadata,set_pagelayout≈ choisir quellayout.tsxenveloppe la page,requires->js_call_amd≈ un<Script strategy="afterInteractive">typé, etnavbar->add≈ alimenter un composant breadcrumb via un contexte. La grande différence : tout est impératif et ordonné — c’est un pipeline de rendu unique, pas un arbre réactif. Une foisheader()émis, il n’y a pas de re-render : ce qui est parti dans le flux HTTP est parti.
6. Notifications
Deux mécanismes coexistent, et il faut choisir le bon selon le scénario.
6.1 \core\notification : la file de session (survit aux redirections)
use core\notification;
// Empile un message qui sera affiché sur la PROCHAINE page rendue —
// y compris après un redirect(). Stocké en session.
notification::success(get_string('itemsaved', 'local_foo'));
notification::warning(get_string('quotanearlyfull', 'local_foo'));
notification::error(get_string('savefailed', 'local_foo'));
notification::info(get_string('syncscheduled', 'local_foo'));
// Le pattern POST-Redirect-GET canonique d'un formulaire :
if ($data = $mform->get_data()) {
local_foo_save_item($data);
notification::success(get_string('itemsaved', 'local_foo'));
redirect(new moodle_url('/local/foo/index.php')); // le message s'affichera là-bas.
}Les notifications empilées sont rendues automatiquement par $OUTPUT->header() (le layout du thème les affiche en haut de la zone de contenu), sous forme d’alertes Bootstrap refermables.
6.2 $OUTPUT->notification() : rendu inline immédiat
// Retourne le HTML d'une alerte, à échoer où VOUS voulez dans la page.
// Ne survit PAS à une redirection : c'est du rendu direct.
echo $OUTPUT->notification(
get_string('nopermissiontoedit', 'local_foo'),
\core\output\notification::NOTIFY_WARNING,
false // 3e paramètre : refermable ou non.
);Utilisez-le pour un message contextuel à un endroit précis de la page ; utilisez \core\notification::xxx() pour un message de résultat d’action suivi d’une redirection.
6.3 redirect() avec message intégré
// redirect() accepte un message : il est automatiquement converti en
// notification de session, puis affiché sur la page cible.
redirect(
new moodle_url('/local/foo/index.php'),
get_string('itemdeleted', 'local_foo'),
null, // délai : null = immédiat.
\core\output\notification::NOTIFY_SUCCESS // type de notification.
);
// ⚠️ redirect() termine le script (die). Aucun code après n'est exécuté.⚠️ Piège :
redirect()lève une exception si des sorties ont déjà été émises ($OUTPUT->header()déjà appelé, ou pire, unechoou un espace avant<?php). Faites tous vos traitements et redirections avant d’entamer le rendu. Le pattern sain : 1) params, 2) login/permissions, 3) traitement des actions + redirect, 4) seulement ensuite le rendu.
7. html_writer : legacy mais omniprésent
\core\output\html_writer génère des balises HTML échappées. Le style moderne lui préfère les templates, mais vous le lirez partout dans le cœur et il reste légitime pour du micro-markup (une cellule de table, un lien dans une chaîne construite dynamiquement).
use core\output\html_writer;
// --- tag : balise avec contenu (le contenu N'EST PAS échappé — à vous de le faire).
echo html_writer::tag('p', s($texte), ['class' => 'lead']);
// → <p class="lead">Texte échappé</p>
// --- empty_tag : balise auto-fermante.
echo html_writer::empty_tag('input', [
'type' => 'hidden', 'name' => 'sesskey', 'value' => sesskey(),
]);
// --- Raccourcis div / span.
echo html_writer::div($contenuhtml, 'card p-3', ['id' => 'foo-card']);
echo html_writer::span(get_string('new'), 'badge bg-info');
// --- link : un <a>. Accepte moodle_url directement (échappement géré).
echo html_writer::link(
new moodle_url('/local/foo/view.php', ['id' => $id]),
format_string($item->name),
['class' => 'fw-bold']
);
// --- start_tag / end_tag pour englober un gros bloc généré en boucle.
echo html_writer::start_tag('ul', ['class' => 'list-unstyled']);
foreach ($items as $item) {
echo html_writer::tag('li', format_string($item->name));
}
echo html_writer::end_tag('ul');
// --- html_table : LA façon rapide de sortir un tableau statique.
$table = new html_table();
$table->head = [get_string('name'), get_string('date'), get_string('actions')];
$table->attributes['class'] = 'table table-striped generaltable';
foreach ($items as $item) {
$table->data[] = [
format_string($item->name),
userdate($item->timecreated),
html_writer::link(new moodle_url('/local/foo/edit.php', ['id' => $item->id]),
get_string('edit')),
];
}
echo html_writer::table($table);Quand l’utiliser encore ? Pour formater le contenu d’une cellule dans une table_sql (section 11), pour un fragment d’une ligne dans une chaîne de langue, pour du HTML généré dans une callback où instancier un template serait disproportionné. Quand ne pas l’utiliser : dès qu’une structure dépasse trois ou quatre balises imbriquées, ou dès qu’un thème pourrait vouloir la personnaliser → template Mustache.
💡 Pour un dev React :
html_writer, c’estReact.createElement()écrit à la main avant l’invention de JSX. Techniquement équivalent, pratiquement illisible au-delà de deux niveaux d’imbrication. Même conclusion que dans l’écosystème React : la syntaxe déclarative (le template) a gagné.
8. moodle_url
moodle_url (nom canonique en 5.x : \core\url) est le constructeur d’URL de Moodle. Toute URL interne doit passer par lui.
use core\url; // ou l'alias historique moodle_url — identique.
// --- Construction : chemin relatif à la racine Moodle + paramètres. ---------
$url = new moodle_url('/mod/quiz/view.php', ['id' => $cmid]);
// $CFG->wwwroot est préfixé automatiquement. Si le site vit dans un
// sous-répertoire (https://exemple.fr/moodle), c'est géré.
// --- Manipulation des paramètres --------------------------------------------
$url->param('page', 2); // ajoute/écrase un paramètre.
$url->params(['sort' => 'name', 'dir' => 'ASC']); // plusieurs d'un coup.
$url->remove_params('page'); // supprime.
$page = $url->get_param('page'); // lit.
$url->set_anchor('section-3'); // fragment #section-3.
// --- Sortie : LA subtilité critique -----------------------------------------
echo $url->out(); // échappé HTML : les & deviennent &
// → pour insertion DIRECTE dans un attribut href écrit à la main.
echo $url->out(false); // brut : & littéraux
// → pour les en-têtes Location, redirect(), JSON,
// export_for_template() (Mustache ré-échappera), JS...
// Règle simple : dans export_for_template(), TOUJOURS ->out(false),
// car {{url}} dans le template appliquera l'échappement HTML lui-même.
// Sinon : double échappement, et vos URLs contiennent des "&amp;".
// --- Comparaison (utile pour marquer l'onglet actif) -------------------------
if ($PAGE->url->compare($url, URL_MATCH_BASE)) { // même script, on ignore les params.
// ...
}
// URL_MATCH_EXACT : identiques ; URL_MATCH_PARAMS : les params de $url
// doivent tous être présents.
// --- URL locale (chemin relatif au wwwroot, pour stockage/redirection sûre) --
$local = $url->out_as_local_url(false); // "/mod/quiz/view.php?id=42"
// Lève une exception si l'URL n'appartient PAS au site → protection
// anti open-redirect quand l'URL vient d'un paramètre utilisateur.
// --- URLs de fichiers servis par pluginfile.php ------------------------------
$fileurl = url::make_pluginfile_url(
$context->id, // contexte du fichier.
'local_foo', // composant.
'attachments', // filearea.
$item->id, // itemid.
'/', // filepath.
$filename // filename.
);
// → https://site/pluginfile.php/CTXID/local_foo/attachments/ITEMID/fichier.pdf
// (le chapitre 05 détaille l'API Fichiers et la callback local_foo_pluginfile.)Pourquoi ne jamais concaténer des query strings à la main ? Quatre raisons :
- Échappement :
'view.php?id=' . $id . '&page=' . $pageproduit un&non échappé en HTML (invalide) — et si vous écrivez&en dur, l’URL est cassée dans un headerLocation. - Encodage :
moodle_urlappliqueurlencode()aux valeurs ; votre concaténation, non (?name=Café & thé→ URL corrompue). - wwwroot : les installations en sous-répertoire ou derrière un reverse proxy cassent les chemins bricolés.
- Évolutivité : ajouter/retirer un paramètre conditionnellement avec des
.'&'.imbriqués est un nid à bugs ;->param()est trivial.
💡 Pour un dev React :
moodle_url≈ l’objetURL/URLSearchParamsdu web moderne, ou lehref={{ pathname, query }}denext/link. Même philosophie : l’URL est une structure de données que l’on manipule, et la sérialisation (avec le bon échappement pour le bon contexte) n’arrive qu’au dernier moment. Le paramètreout(false)est l’équivalent de choisir entretoString()(brut) et l’échappement automatique de JSX danshref={...}.
9. Navigation API
9.1 Les trois navigations de Boost
Depuis Moodle 4.0 (et toujours vrai en 5.2), l’interface s’organise autour de trois systèmes de navigation — la « flat navigation » (le tiroir de gauche des Moodle 3.x/Boost première génération) a disparu :
- Navigation primaire (
$PAGE->primarynav) : la barre horizontale du haut — Accueil, Tableau de bord, Mes cours, Administration du site. Peu extensible par les plugins (c’est volontaire) ; les hooks du cœur permettent au thème de la personnaliser. - Navigation secondaire (
$PAGE->secondarynav) : les onglets de page sous l’en-tête — dans un cours : Cours, Paramètres, Participants, Notes, Rapports, Plus. C’est ici que les liens contextuels d’un plugin apparaissent le plus souvent, généralement dérivés automatiquement de la navigation de réglages. - Navigation de réglages / administration (
$PAGE->settingsnav) : l’ancien bloc « Administration », toujours vivant comme structure de données : la navigation secondaire et le menu « Plus » s’en nourrissent.
S’y ajoute l’arbre global $PAGE->navigation (structure complète site → cours → sections → activités), qui alimente le fil d’ariane et l’index de cours.
9.2 Comment un plugin s’insère : les callbacks de lib.php
Un plugin n’accède pas directement aux menus : il implémente des callbacks nommées par convention dans son lib.php, que le cœur appelle en construisant les arbres.
| Callback | Type de plugin | S’insère dans |
|---|---|---|
{modname}_extend_settings_navigation($settingsnav, $modnode) | Activité (mod) | Menu réglages de l’activité |
{plugin}_extend_navigation_course($navigation, $course, $context) | Tout plugin | Menu d’administration du cours (onglet « Plus » de la nav secondaire) |
{plugin}_extend_navigation_user_settings(...) | Tout plugin | Préférences utilisateur |
{plugin}_extend_navigation_frontpage(...) | Tout plugin | Navigation de la page d’accueil |
local_foo_extend_navigation(global_navigation $nav) | Plugin local | L’arbre global |
local_foo_extend_settings_navigation($settingsnav, $context) | Plugin local | L’arbre de réglages courant |
9.3 Exemple complet : ajouter un item dans l’administration d’un cours
Dans local/foo/lib.php :
<?php
defined('MOODLE_INTERNAL') || die();
use core\url;
/**
* Ajoute un lien "Rapport Foo" dans la navigation d'administration du cours.
* Apparaît dans l'onglet "Plus" de la navigation secondaire du cours.
*
* @param navigation_node $navigation Le nœud d'administration du cours.
* @param stdClass $course Le cours.
* @param context_course $context Le contexte du cours.
*/
function local_foo_extend_navigation_course(navigation_node $navigation,
stdClass $course,
context_course $context): void {
// TOUJOURS vérifier la permission : la callback est appelée pour tout
// le monde, c'est à vous de filtrer.
if (!has_capability('local/foo:viewreport', $context)) {
return;
}
$url = new url('/local/foo/report.php', ['courseid' => $course->id]);
// Méthode 1 : add() directement sur le nœud parent.
$navigation->add(
get_string('fooreport', 'local_foo'), // libellé.
$url, // URL (ou null pour un dossier).
navigation_node::TYPE_SETTING, // type de nœud.
null, // shorttext.
'local_foo_report', // clé unique (pour retrouver le nœud).
new pix_icon('i/report', '') // icône.
);
// Méthode 2 : create() puis add_node(), utile pour contrôler la position.
$node = navigation_node::create(
get_string('foosettings', 'local_foo'),
new url('/local/foo/coursesettings.php', ['courseid' => $course->id]),
navigation_node::TYPE_SETTING,
null,
'local_foo_settings'
);
$node->set_force_into_more_menu(true); // reste dans le menu "Plus",
// ne devient jamais un onglet.
$navigation->add_node($node);
}Pour une activité (module), la signature diffère légèrement — exemple dans mod/monmod/lib.php :
/**
* Étend le menu de réglages de l'activité (onglet "Plus" de l'activité).
*
* @param settings_navigation $settingsnav
* @param navigation_node $monmodnode Le nœud racine des réglages de CETTE instance.
*/
function monmod_extend_settings_navigation(settings_navigation $settingsnav,
navigation_node $monmodnode): void {
if (has_capability('mod/monmod:manage', $settingsnav->get_page()->cm->context)) {
$monmodnode->add(
get_string('manageentries', 'mod_monmod'),
new moodle_url('/mod/monmod/manage.php',
['id' => $settingsnav->get_page()->cm->id]),
navigation_node::TYPE_SETTING
);
}
}Quelques méthodes utiles de navigation_node : add() (enfant + retour du nouveau nœud), find($key, $type) (retrouver un nœud existant pour s’insérer à côté), make_active() (marquer actif → onglet surligné + fil d’ariane), remove(), set_show_in_secondary_navigation(true/false), set_force_into_more_menu().
⚠️ Piège : les arbres de navigation sont mis en cache par session pour la navigation globale ; et vos callbacks sont appelées sur chaque chargement de page concerné. Deux conséquences : (1) après modification d’une callback de navigation, déconnectez-vous/reconnectez-vous ou purgez les caches si le menu semble figé ; (2) ne faites aucune requête SQL coûteuse dans ces callbacks — elles s’exécutent sur toutes les pages du cours. Testez une capability, construisez une URL, point.
📚 Aller plus loin : moodledev.io/docs/apis/subsystems/navigation documente les trois navigations et les classes
\core\navigation\views\secondary/primary, que les activités peuvent même sous-classer (mod_quiz\navigation\views\secondary) pour réordonner totalement leurs onglets.
10. Pagination : paging_bar
Le composant paging_bar (\core\output\paging_bar) rend la barre « 1 2 3 … Suivant ». Le pattern complet, avec optional_param :
<?php
require(__DIR__ . '/../../config.php');
// Convention de nommage universelle dans Moodle : page (0-indexée !) et perpage.
$page = optional_param('page', 0, PARAM_INT);
$perpage = optional_param('perpage', 20, PARAM_INT);
require_login();
$context = context_system::instance();
// L'URL de base DOIT contenir tous les paramètres de filtre/tri courants,
// SAUF "page" que paging_bar gère lui-même.
$baseurl = new moodle_url('/local/foo/index.php', ['perpage' => $perpage]);
$PAGE->set_url($baseurl);
$PAGE->set_context($context);
$PAGE->set_title(get_string('pluginname', 'local_foo'));
$PAGE->set_heading(get_string('pluginname', 'local_foo'));
// Compte total (pour connaître le nombre de pages) + tranche courante.
$totalcount = $DB->count_records('local_foo_items');
$items = $DB->get_records('local_foo_items', null, 'timecreated DESC',
'*',
$page * $perpage, // offset = page × perpage.
$perpage); // limite.
echo $OUTPUT->header();
// ... rendu de la liste (template Mustache, table, etc.) ...
// La barre de pagination : (total, page courante, taille de page, URL de base).
// Elle génère des liens baseurl + "&page=N". Ne s'affiche pas s'il n'y a
// qu'une seule page.
echo $OUTPUT->paging_bar($totalcount, $page, $perpage, $baseurl);
echo $OUTPUT->footer();⚠️ Piège :
pageest 0-indexée (page 0 = première page), et l’offset SQL estpage * perpage— pas(page - 1) * perpage. Si votre première page est vide ou que la pagination « saute » des enregistrements, c’est presque toujours cette confusion. Autre classique : oublier de reporter les paramètres de filtre dans$baseurl→ cliquer sur « page 2 » réinitialise les filtres.
11. Tables : flexible_table et table_sql
Pour lister des données, Moodle fournit flexible_table (vous poussez les lignes vous-même) et surtout sa sous-classe table_sql (vous fournissez la requête ; tri, pagination, export sont gérés pour vous). Elles vivent dans lib/tablelib.php (avec, depuis 4.x, des compléments dans \core_table pour les tables dynamiques filtrables en AJAX).
11.1 Classe complète : table_sql
local/foo/classes/table/items_table.php :
<?php
namespace local_foo\table;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->libdir . '/tablelib.php'); // table_sql n'est pas autoloadée !
use core\output\html_writer;
use core\url;
use table_sql;
/**
* Table de listing des items de local_foo : tri, pagination, export.
*/
class items_table extends table_sql {
/** @var \context Contexte pour le formatage. */
protected \context $context;
/** @var bool Droit de gestion (colonne actions). */
protected bool $canmanage;
/**
* @param string $uniqueid Identifiant unique de la table (persistance du tri
* dans les préférences utilisateur).
* @param \context $context
*/
public function __construct(string $uniqueid, \context $context) {
parent::__construct($uniqueid);
$this->context = $context;
$this->canmanage = has_capability('local/foo:manage', $context);
// --- Colonnes : les clés correspondent aux alias du SELECT -----------
$columns = ['name', 'category', 'timecreated', 'visible'];
$headers = [
get_string('name'),
get_string('category', 'local_foo'),
get_string('date'),
get_string('visible'),
];
if ($this->canmanage) {
$columns[] = 'actions';
$headers[] = get_string('actions');
}
$this->define_columns($columns);
$this->define_headers($headers);
// --- Tri ----------------------------------------------------------------
$this->sortable(true, 'timecreated', SORT_DESC); // tri par défaut.
$this->no_sorting('actions'); // pas de tri sur les actions.
$this->collapsible(false); // pas de colonnes repliables.
// --- Export -----------------------------------------------------------
// "actions" ne doit pas apparaître dans le CSV : on la marque non exportable.
$this->column_nosort[] = 'actions';
$this->is_downloadable(true);
$this->show_download_buttons_at([TABLE_P_BOTTOM]);
}
/**
* Formatage de la colonne "name" : convention col_{nomcolonne}().
* Si aucune méthode col_xxx n'existe, la valeur brute est affichée
* (échappée en mode web).
*
* @param \stdClass $row La ligne SQL complète.
* @return string
*/
public function col_name(\stdClass $row): string {
$name = format_string($row->name, true, ['context' => $this->context]);
if ($this->is_downloading()) {
return $name; // export : texte brut, pas de lien.
}
return html_writer::link(
new url('/local/foo/view.php', ['id' => $row->id]),
$name
);
}
public function col_timecreated(\stdClass $row): string {
return userdate($row->timecreated, get_string('strftimedatetime', 'langconfig'));
}
public function col_visible(\stdClass $row): string {
return $row->visible ? get_string('yes') : get_string('no');
}
/**
* Colonne calculée qui n'existe pas en SQL : "actions".
* (Pour des colonnes inconnues à traiter en masse, on peut aussi
* surcharger other_cols($colname, $row).)
*/
public function col_actions(\stdClass $row): string {
global $OUTPUT;
if ($this->is_downloading()) {
return '';
}
$edit = $OUTPUT->action_icon(
new url('/local/foo/edit.php', ['id' => $row->id]),
new \core\output\pix_icon('t/edit', get_string('edit'))
);
$delete = $OUTPUT->action_icon(
new url('/local/foo/delete.php', ['id' => $row->id, 'sesskey' => sesskey()]),
new \core\output\pix_icon('t/delete', get_string('delete'))
);
return $edit . ' ' . $delete;
}
/**
* Filet de sécurité pour toute colonne sans méthode dédiée.
*/
public function other_cols($colname, $row) {
// Par défaut : échapper la valeur brute (sécurité en mode web).
return s($row->$colname ?? '');
}
}11.2 La page de listing complète, avec filtre et export
local/foo/manage.php :
<?php
require(__DIR__ . '/../../config.php');
$download = optional_param('download', '', PARAM_ALPHA); // '' | csv | excel...
$category = optional_param('category', 0, PARAM_INT); // filtre.
require_login();
$context = context_system::instance();
require_capability('local/foo:view', $context);
$baseurl = new moodle_url('/local/foo/manage.php', ['category' => $category]);
$PAGE->set_url($baseurl);
$PAGE->set_context($context);
$PAGE->set_title(get_string('manageitems', 'local_foo'));
$PAGE->set_heading(get_string('manageitems', 'local_foo'));
$PAGE->set_pagelayout('admin');
// --- Construction de la table ------------------------------------------------
$table = new \local_foo\table\items_table('local_foo_items_table', $context);
// set_sql(fields, from, where, params) : la table gère ORDER BY, LIMIT, COUNT.
$where = '1=1';
$params = [];
if ($category) {
$where .= ' AND i.categoryid = :categoryid';
$params['categoryid'] = $category;
}
$table->set_sql(
'i.id, i.name, i.timecreated, i.visible, c.name AS category',
'{local_foo_items} i JOIN {local_foo_categories} c ON c.id = i.categoryid',
$where,
$params
);
// L'URL de base sert aux liens de tri et de pagination : elle doit
// embarquer les filtres courants (sinon trier = perdre le filtre).
$table->define_baseurl($baseurl);
// --- Mode export : PAS de header/footer, la table streame le fichier ---------
if ($download) {
$table->is_downloading($download, 'items_export', get_string('items', 'local_foo'));
$table->out(0, false); // 0 = pas de pagination en export.
exit; // le fichier est déjà parti, on s'arrête.
}
// --- Mode web ------------------------------------------------------------------
echo $OUTPUT->header();
echo $OUTPUT->heading(get_string('manageitems', 'local_foo'));
// Un petit formulaire de filtre (en vrai projet : un moodleform ou le
// composant core/search_input — ici version minimale).
$categories = $DB->get_records_menu('local_foo_categories', null, 'name', 'id, name');
echo html_writer::start_tag('form', ['method' => 'get', 'class' => 'mb-3']);
echo html_writer::select($categories, 'category', $category, get_string('allcategories', 'local_foo'));
echo html_writer::empty_tag('input', ['type' => 'submit', 'value' => get_string('filter'),
'class' => 'btn btn-secondary ms-2']);
echo html_writer::end_tag('form');
// out(pagesize, useinitialsbar) : rend la table, avec tri cliquable,
// pagination automatique (paramètres page/tsort/tdir gérés pour vous),
// et boutons d'export (CSV, Excel, ODS, JSON, PDF) en bas.
$table->out(25, true);
echo $OUTPUT->footer();Points remarquables de table_sql :
- Tri automatique : cliquer un en-tête ajoute
tsort/tdirà l’URL, etget_sql_sort()construit l’ORDER BY— validé contre les colonnes déclarées, donc pas d’injection SQL possible via le tri. - Pagination intégrée :
out(25, true)pagine tout seul ; inutile de gérerpaging_barmanuellement. - Export multi-format :
is_downloadable(true)+ le testif ($download)en tête de script suffisent. Attention, le test d’export doit intervenir avant$OUTPUT->header(), sinon le fichier téléchargé contiendrait le HTML de la page. - Persistance du tri : le
$uniqueiddu constructeur sert de clé de préférence utilisateur — deux tables différentes ne doivent jamais partager le même id.
⚠️ Piège : dans
set_sql(), la liste de champs doit impérativement inclureiden premier (ou au moins un champ unique en premier) :$DB->get_records_sqlutilise la première colonne comme clé du tableau résultat, et des doublons écraseraient silencieusement des lignes. De plus, tout alias utilisé comme colonne triable doit exister dans leSELECT(trier surcategoryexigec.name AS category).
💡 Pour un dev React :
table_sqlest l’anti-TanStack-Table : au lieu d’un modèle headless côté client où vous composez colonnes, tri et pagination en JS, tout est rendu serveur et piloté par l’URL (?page=2&tsort=name&tdir=4). C’est moins interactif, mais gratuit en maintenance et naturellement accessible/indexable. Pour l’interactivité sans rechargement, Moodle 4/5 propose les tables dynamiques (\core_table\dynamic+ module JScore_table/dynamic) qui rechargent le HTML de la table en AJAX — le pont entre les deux mondes.
📚 Aller plus loin : la page moodledev.io/docs/apis/plugintypes/mod (sections output) et le code de
admin/user.phpoumod/assign/gradingtable.phpsont d’excellents exemples réels detable_sqlpoussée à fond (jointures complexes, colonnes utilisateur, actions de masse).
Résumé — points clés
- Trois âges du rendu :
echobrut (interdit),html_writer(toléré pour les micro-fragments), renderables + templates Mustache (le standard). Le HTML appartient aux templates, la préparation des données au PHP. $OUTPUTest le renderer du cœur, surchargeable par le thème (theme_boost\output\core_renderer).header()/footer()rendent le layout complet du thème ; entre les deux, votre contenu. Depuis Moodle 4.5/5.x, les composants output sont namespacés :\core\output\html_writer,\core\output\pix_icon,\core\url(ex-moodle_url)…- Renderable/templatable :
renderableest un marqueur,export_for_template()retourne des données « bêtes » et JSON-sérialisables (booléens précalculés, URLs via->out(false), texte déjà passé parformat_string/format_text).$OUTPUT->render($obj)résoutrender_nomclasse()sur le renderer du plugin, sinon rend le templatecomponent/nomclasse. Le renderer de plugin est devenu optionnel :render_from_template()direct est parfaitement acceptable. - Mustache : logic-less strict.
{{var}}échappe,{{{var}}}seulement pour du HTML nettoyé. Helpers :{{#str}},{{#pix}},{{#quote}},{{#js}},{{#shortentext}},{{#userdate}},{{#cleanstr}}. L’en-tête@template+ « Example context (json) » est obligatoire. Le thème peut surcharger tout template viatheme/x/templates/component/.... $PAGE:set_url()(avec params, en premier),set_context()/set_course()/set_cm(),set_title(),set_heading(),set_pagelayout(). Tout avant$OUTPUT->header()— saufjs_call_amd(), accepté après.$PAGE->requires->js_call_amd()est le pont officiel PHP → JavaScript.- Notifications :
\core\notification::success()/error()/...survivent aux redirections (session) ;$OUTPUT->notification()est un rendu inline immédiat ;redirect($url, $message, null, $type)combine les deux. moodle_url: jamais de concaténation de query strings.out()pour du HTML écrit à la main,out(false)pour redirect/JSON/export_for_template.make_pluginfile_url()pour les fichiers,out_as_local_url()contre les open redirects.- Navigation : primaire / secondaire / réglages. Un plugin s’insère via des callbacks
lib.php(*_extend_navigation_course,*_extend_settings_navigation…) avecnavigation_node::create()/add()— toujours protégées par un test de capability, jamais de SQL lourd. - Pagination :
optional_param('page', 0)(0-indexée) +$OUTPUT->paging_bar($total, $page, $perpage, $baseurl), filtres reportés dans$baseurl. table_sql:define_columns/define_headers,set_sql()(aveciden tête du SELECT),col_xxx()pour le formatage,is_downloadable()pour l’export (à traiter avantheader()),out($pagesize)gère tri + pagination + export.
Navigation : ← 03 — Form API · 05 — Fichiers, strings, config →