Crear un plugin para Moodle parece fácil durante los primeros diez minutos. Creas una carpeta en local/, añades version.php, visitas «Administración del sitio → Notificaciones» y aparece la confirmación de instalación. La pantalla está en verde. El plugin existe.
Entonces llega el siguiente requisito:
Queremos que cada estudiante valore el curso. Una sola respuesta por curso, aunque pueda cambiarla más adelante. El profesorado debe ver la media y los comentarios, pero no los nombres. Y, si una persona solicita eliminar sus datos, el plugin tiene que saber encontrarlos y borrarlos.
La petición cabe en tres frases. Sin embargo, obliga a decidir quién puede actuar, dentro de qué curso, qué datos acepta el formulario, cómo se evita una respuesta duplicada, quién consulta el informe y qué ocurre con la información personal. El «hola mundo» ya no sirve como mapa. Si seguimos añadiendo condiciones a index.php, pronto tendremos un archivo que hace de pantalla, formulario, regla de negocio, control de acceso y repositorio al mismo tiempo.
Ese es el momento en el que un plugin empieza a parecerse a software de verdad.
Este tutorial comienza ahí. Vamos a construir local_coursefeedback, un pequeño «pulso del curso» para Moodle 5.x: el alumnado podrá valorar de 1 a 5, añadir un comentario y actualizar su respuesta; el profesorado consultará un informe anonimizado; y Moodle podrá exportar o borrar los datos personales guardados.
El resultado seguirá siendo lo bastante pequeño para recorrerlo de principio a fin. No obstante, ya incluirá las piezas que separan una demostración de una funcionalidad mantenible: Form API, capacidades, contextos, XMLDB, DML, una capa mínima de acceso a datos y Privacy API. No se trata de copiar doce archivos. Se trata de entender qué decisión protege cada uno.
La idea que guiará todo el tutorial: un plugin no se vuelve real porque tenga muchas líneas, sino porque debe proteger un circuito completo: entrada, contexto, permisos, datos, lectura y privacidad.
Qué aprenderás al crear un plugin para Moodle
Al terminar no solo tendrás código instalable. Podrás reconocer las fronteras que se repiten en casi cualquier extensión de Moodle:
- Elegir entre un plugin
local, un módulo de actividad o un bloque según el comportamiento que necesitas. - Relacionar el nombre Frankenstyle, la carpeta y el componente sin inconsistencias.
- Modelar una tabla con XMLDB y convertir «una respuesta por curso» en una restricción real.
- Autorizar acciones con capacidades dentro del contexto del curso, sin depender de nombres de roles.
- Recibir y validar datos con Form API, y persistirlos mediante la API DML.
- Separar la coordinación HTTP, el formulario y el acceso a datos sin introducir una arquitectura ceremonial.
- Construir una lectura diferente para el informe y minimizar los datos que muestra.
- Incorporar Privacy API desde el diseño, no como una tarea pendiente para producción.
El resultado del tutorial
Así, un estudiante podrá enviar una valoración y modificarla más adelante. Un docente editor o gestor podrá consultar el total de respuestas, la media y los comentarios sin nombres. La base de datos garantizará una sola fila por persona y curso, y Moodle sabrá cómo localizar, exportar y eliminar los datos personales del plugin.
Cómo utilizar esta guía para crear un plugin para Moodle
| Si estás en este punto | Empieza por | Qué te llevarás |
|---|---|---|
| Es tu primera extensión de Moodle | Lee el modelo mental y la elección del tipo de plugin | Entenderás el mapa antes de tocar código |
| Ya has creado un bloque sencillo | Ve a la estructura y recorre los doce pasos | Pasarás de renderizar contenido a gestionar datos y permisos |
| Ya desarrollas plugins | Revisa XMLDB, capacidades, repositorio y privacidad | Podrás discutir las decisiones y adaptar el patrón |
| Quieres evaluar el ejemplo | Salta a instalación, matriz de pruebas y límites | Sabrás qué valida el tutorial y qué falta para producción |
Por ejemplo, si todavía no has creado ninguna extensión, puedes empezar por la guía para crear tu primer bloque en Moodle. Allí se presentan la estructura mínima, las cadenas de idioma y el ciclo de instalación con un ejemplo más corto.
Qué necesitas antes de crear un plugin para Moodle
- Una instalación de desarrollo de Moodle 5.0 o posterior; nunca pruebes por primera vez en producción.
- Acceso al código de Moodle y una cuenta administradora para instalar el componente.
- Conocimientos básicos de PHP, clases, arrays y formularios.
- Dos usuarios de prueba con perfiles distintos: estudiante y docente editor.
Por tanto, el ejemplo no usa JavaScript, servicios externos, colas ni plantillas Mustache. Esa renuncia es deliberada: aprenderemos el circuito esencial sin esconderlo bajo más infraestructura de la necesaria.
Crear un plugin para Moodle más allá del «hola mundo»
Un ejemplo mínimo suele detenerse después de imprimir una cadena con $OUTPUT. Aquí recorreremos un ciclo completo:

Sin embargo, la ruta de escritura y la de lectura no tienen los mismos permisos. El estudiante puede guardar su respuesta, pero no consultar las de los demás. El docente puede abrir el informe, pero el ejemplo no le entrega los identificadores de los participantes. Esta separación no es un detalle posterior: forma parte del diseño.
De este modo, podemos leer el circuito como cinco fronteras. Cada una responde a una pregunta diferente:
| Frontera | Pregunta | Pieza principal |
|---|---|---|
| Integración | ¿Cómo descubre Moodle el componente? | version.php, nombres y callbacks |
| Autorización | ¿Quién puede hacer cada acción y dónde? | Contexto y capacidades |
| Entrada | ¿Qué datos acepta el plugin? | Form API y validación |
| Persistencia | ¿Qué regla deben cumplir los datos? | XMLDB, DML y repositorio |
| Gobierno | ¿Quién puede leer, exportar o borrar? | Informe y Privacy API |
Modelo mental reutilizable: cada vez que añadas una pantalla, una tarea programada, un servicio web o un evento, vuelve a recorrer estas cinco fronteras. Te obligan a pensar en algo más que «dónde pongo este código».
Qué hará exactamente el plugin
| Actor | Acción | Regla |
|---|---|---|
| Estudiante | Valorar el curso del 1 al 5 | Solo una respuesta por curso |
| Estudiante | Añadir un comentario opcional | Máximo 1000 caracteres |
| Estudiante | Volver a guardar | Actualiza la respuesta anterior |
| Docente editor o gestor | Consultar el informe | Ve resultados agregados y comentarios sin nombre |
| Moodle | Exportar o borrar datos de una persona | El plugin participa mediante Privacy API |
Por supuesto, no estamos construyendo una herramienta de encuestas preparada para producción. Faltarían aspectos como ventanas de participación, escalas configurables, moderación, exportación CSV, eventos, pruebas automatizadas y análisis estadístico. El objetivo es aprender a crear un plugin para Moodle con una estructura suficientemente real como para poder evolucionarla.
Antes de crear un plugin para Moodle: elegir el tipo correcto
Antes de crear un plugin para Moodle debemos elegir el tipo más específico. Si quisiéramos que el profesorado añadiera varias encuestas como actividades dentro del curso, con fechas y configuración propia para cada instancia, probablemente deberíamos desarrollar un módulo de actividad en mod/.
En cambio, en este ejemplo la funcionalidad es transversal: aparece automáticamente en todos los cursos y no necesita que el docente cree una actividad. Por eso encaja razonablemente como plugin local. La carpeta será local/coursefeedback y, siguiendo la convención Frankenstyle, su nombre de componente será local_coursefeedback.
| Tipo | Encaja cuando… | No es la mejor elección cuando… |
|---|---|---|
local | La función es transversal al sitio y no necesita instancias creadas por el docente | El usuario debe añadir, configurar y calificar varias instancias dentro de un curso |
mod | Cada actividad tiene configuración, fechas, participación y ciclo de vida propios | Solo necesitas una utilidad global o una integración de apoyo |
block | La función se presenta como contenido auxiliar en una región del curso o del panel | El bloque sería únicamente una puerta hacia una aplicación con dominio propio |

Una carpeta local no es un cajón de sastre. Que un plugin pueda guardar tablas, añadir páginas y escuchar eventos no significa que todo deba implementarse como local_. El comportamiento y el ciclo de vida de la funcionalidad deben decidir el tipo.
Si quieres profundizar en la alternativa visual, la segunda parte de la guía de bloques profesionales explica configuración, seguridad, plantillas y caché. La comparación ayuda a evitar que un bloque acabe absorbiendo responsabilidades que pertenecen a otro tipo de plugin.
La estructura de archivos para crear un plugin para Moodle
Para crear este plugin para Moodle utilizaremos los siguientes archivos:
| Ruta | Responsabilidad |
|---|---|
version.php | Identidad, versión y compatibilidad |
db/install.xml | Esquema inicial de la tabla |
db/access.php | Capacidades y roles por defecto |
lang/en/local_coursefeedback.php | Cadenas base en inglés |
lang/es/local_coursefeedback.php | Traducción al castellano |
classes/form/feedback_form.php | Definición y validación del formulario |
classes/local/feedback_repository.php | Lectura y escritura de valoraciones |
index.php | Página para enviar o editar la respuesta |
report.php | Informe protegido para docentes y gestores |
lib.php | Callback mínimo para añadir enlaces a la navegación |
classes/privacy/provider.php | Descripción, exportación y borrado de datos personales |

Así, la estructura ya cuenta una historia. Sigue el mapa de archivos comunes de los plugins de Moodle: las páginas HTTP son finas, el formulario vive en una clase autocargada, la persistencia está aislada y lib.php solo contiene el callback que Moodle necesita descubrir.
Lee el árbol por responsabilidades. Los archivos de db/ declaran contratos instalables; lang/ concentra la interfaz traducible; classes/ contiene código autocargado; las páginas de la raíz coordinan peticiones; y lib.php expone únicamente los callbacks que Moodle busca por nombre.
1. Declarar el componente en version.php
<?php
defined('MOODLE_INTERNAL') || die();
$plugin->component = 'local_coursefeedback';
$plugin->version = 2026081000;
$plugin->requires = 2025041400;
$plugin->maturity = MATURITY_BETA;
$plugin->release = '1.0.0';Como detalla la referencia oficial de version.php, component debe coincidir con el nombre Frankenstyle. version permite a Moodle detectar cambios. requires establece Moodle 5.0 como versión mínima; el identificador de Moodle 5.0.0 es 2025041400. maturity comunica el estado del componente y release ofrece una versión legible para administradores.
Además, incrementaremos version cuando Moodle necesite ejecutar el proceso de actualización o invalidar cachés asociadas al componente. La guía oficial de actualización de plugins lo exige, entre otros casos, tras cambios en db/, JavaScript, nuevas clases autocargadas, ajustes o cadenas de idioma. Un cambio puramente interno que no afecta a esos contratos puede no necesitarlo, pero publicar una nueva versión del plugin sin avanzar el número termina generando despliegues difíciles de diagnosticar.
Pregunta de control: si copias la carpeta en otra instalación, ¿Moodle puede identificar sin ambigüedad el componente, comprobar si es compatible y decidir si debe actualizarlo? Si la respuesta depende del nombre de la carpeta o de una nota externa, version.php todavía no está haciendo todo su trabajo.
2. Crear la tabla con XMLDB
Al crear un plugin para Moodle con datos propios no generamos la tabla mediante SQL específico. En nuestro caso almacenará el curso, el usuario, la valoración, el comentario y las fechas. El archivo db/install.xml, que conviene crear y mantener con el editor XMLDB de Moodle, describe su estado final para una instalación nueva.
<?xml version="1.0" encoding="UTF-8" ?>
<XMLDB PATH="local/coursefeedback/db" VERSION="2026081000"
COMMENT="XMLDB file for local_coursefeedback"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="../../../lib/xmldb/xmldb.xsd">
<TABLES>
<TABLE NAME="local_coursefeedback"
COMMENT="Stores one feedback response per user and course">
<FIELDS>
<FIELD NAME="id" TYPE="int" LENGTH="10"
NOTNULL="true" SEQUENCE="true"/>
<FIELD NAME="courseid" TYPE="int" LENGTH="10"
NOTNULL="true" SEQUENCE="false"/>
<FIELD NAME="userid" TYPE="int" LENGTH="10"
NOTNULL="true" SEQUENCE="false"/>
<FIELD NAME="rating" TYPE="int" LENGTH="2"
NOTNULL="true" SEQUENCE="false"/>
<FIELD NAME="comment" TYPE="text"
NOTNULL="false" SEQUENCE="false"/>
<FIELD NAME="timecreated" TYPE="int" LENGTH="10"
NOTNULL="true" SEQUENCE="false"/>
<FIELD NAME="timemodified" TYPE="int" LENGTH="10"
NOTNULL="true" SEQUENCE="false"/>
</FIELDS>
<KEYS>
<KEY NAME="primary" TYPE="primary" FIELDS="id"/>
<KEY NAME="courseid" TYPE="foreign" FIELDS="courseid"
REFTABLE="course" REFFIELDS="id"/>
<KEY NAME="userid" TYPE="foreign" FIELDS="userid"
REFTABLE="user" REFFIELDS="id"/>
</KEYS>
<INDEXES>
<INDEX NAME="courseuser" UNIQUE="true"
FIELDS="courseid, userid"/>
</INDEXES>
</TABLE>
</TABLES>
</XMLDB>De este modo, el índice único courseuser convierte una regla funcional en una garantía de datos: una persona no puede tener dos filas para el mismo curso. La interfaz intentará actualizar el registro existente, pero la base de datos sigue protegiendo la invariancia.

No escribas el prefijo mdl_. En XMLDB y en la API DML utilizamos local_coursefeedback. Moodle aplica el prefijo real de cada instalación.
Por último, conviene generar y modificar este archivo con el editor XMLDB integrado en «Administración del sitio → Desarrollo → Editor XMLDB». Si alteramos la tabla después de publicar la primera versión, install.xml seguirá describiendo el estado final, pero las instalaciones existentes necesitarán los pasos correspondientes en db/upgrade.php.
3. Separar permisos con db/access.php
Iniciar sesión no basta. Para crear un plugin para Moodle que respete los roles del campus debemos preguntar qué puede hacer cada persona dentro del contexto correcto.
<?php
defined('MOODLE_INTERNAL') || die();
$capabilities = [
'local/coursefeedback:submit' => [
'riskbitmask' => RISK_SPAM,
'captype' => 'write',
'contextlevel' => CONTEXT_COURSE,
'archetypes' => [
'student' => CAP_ALLOW,
],
],
'local/coursefeedback:viewreports' => [
'riskbitmask' => RISK_PERSONAL,
'captype' => 'read',
'contextlevel' => CONTEXT_COURSE,
'archetypes' => [
'editingteacher' => CAP_ALLOW,
'manager' => CAP_ALLOW,
],
],
];Además, las dos capacidades viven en CONTEXT_COURSE. La Access API de Moodle permite así que una misma persona tenga permiso en un curso y no en otro. Los arquetipos solo proponen valores durante la instalación: el administrador puede modificar después las capacidades de cada rol.
En las páginas no comprobaremos si el usuario «es estudiante» o «es profesor». Comprobaremos capacidades. Los nombres de rol pueden cambiar, combinarse o personalizarse; la autorización debe apoyarse en lo que la persona puede hacer.

Checkpoint 1: ya existen tres contratos
Moodle puede identificar el componente, sabe qué tabla debe crear y conoce las acciones autorizables. Todavía no hay pantalla, pero ya hemos definido identidad, datos y permisos. Esta secuencia reduce el riesgo de diseñar primero una interfaz y preguntar después quién debería utilizarla.
4. Añadir las cadenas de idioma
Todo texto visible se recupera con get_string(). Según la documentación de los archivos comunes de un plugin, el archivo inglés es obligatorio aunque el campus principal esté en castellano.
Por ejemplo, el siguiente bloque es un extracto para mostrar el patrón. El paquete incluye también las cadenas de la escala, el informe, los errores de validación y la Privacy API. En ambos idiomas deben existir exactamente las mismas claves.
<?php
$string['pluginname'] = 'Course pulse';
$string['coursefeedback'] = 'Course pulse';
$string['rating'] = 'Rating';
$string['comment'] = 'Comment';
$string['comment_help'] = 'Optional. Maximum 1,000 characters.';
$string['savefeedback'] = 'Save feedback';
$string['feedbacksaved'] = 'Your feedback has been saved.';
$string['coursefeedback:submit'] = 'Submit course feedback';
$string['coursefeedback:viewreports'] = 'View course feedback reports';La versión castellana usa las mismas claves en lang/es/local_coursefeedback.php. Las cadenas de las capacidades se llaman coursefeedback:submit y coursefeedback:viewreports, sin el prefijo local/.
No traduzcas dentro del código. La cadena en inglés es el contrato base; los paquetes de idioma aportan las traducciones. Esto permite que la misma instalación muestre el plugin en varios idiomas sin bifurcar la lógica.
5. Crear un plugin para Moodle: definir el formulario con Form API
Al crear un plugin para Moodle podríamos escribir un <form> manual y leer $_POST. También tendríamos que resolver la clave de sesión, la limpieza de parámetros, los mensajes de error, la accesibilidad y la integración visual. La Forms API de Moodle ya reúne esas preocupaciones.
<?php
namespace local_coursefeedback\form;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->libdir . '/formslib.php');
final class feedback_form extends \moodleform {
public function definition(): void {
$mform = $this->_form;
$ratings = [
0 => get_string('chooserating', 'local_coursefeedback'),
1 => get_string('rating1', 'local_coursefeedback'),
2 => get_string('rating2', 'local_coursefeedback'),
3 => get_string('rating3', 'local_coursefeedback'),
4 => get_string('rating4', 'local_coursefeedback'),
5 => get_string('rating5', 'local_coursefeedback'),
];
$mform->addElement(
'select',
'rating',
get_string('rating', 'local_coursefeedback'),
$ratings
);
$mform->setType('rating', PARAM_INT);
$mform->addElement(
'textarea',
'comment',
get_string('comment', 'local_coursefeedback'),
['rows' => 6, 'maxlength' => 1000]
);
$mform->setType('comment', PARAM_TEXT);
$mform->addHelpButton('comment', 'comment', 'local_coursefeedback');
$this->add_action_buttons(
false,
get_string('savefeedback', 'local_coursefeedback')
);
}
public function validation($data, $files): array {
$errors = parent::validation($data, $files);
$rating = (int) ($data['rating'] ?? 0);
if ($rating < 1 || $rating > 5) {
$errors['rating'] = get_string(
'invalidrating',
'local_coursefeedback'
);
}
if (\core_text::strlen(trim($data['comment'] ?? '')) > 1000) {
$errors['comment'] = get_string(
'commentmaxlength',
'local_coursefeedback'
);
}
return $errors;
}
}setType() limpia el valor recibido según el tipo esperado. El atributo HTML maxlength mejora la experiencia, pero no sustituye la validación del servidor. Una petición se puede construir sin utilizar el navegador.
En la escala reservamos el valor 0 para «Elige una valoración» y la validación acepta únicamente del 1 al 5. El comentario pasa por PARAM_TEXT y vuelve a comprobarse con core_text::strlen(), que cuenta correctamente texto multibyte. Son decisiones pequeñas, pero convierten una expectativa de interfaz en un contrato verificable en el servidor.
La Form API no guarda nada. Define, presenta y valida el formulario. La decisión sobre insertar o actualizar pertenece a nuestra lógica de aplicación.
6. Encapsular el acceso a datos
En principio, para un ejemplo corto podríamos usar $DB directamente desde index.php. Sin embargo, crear una clase pequeña evita que la página termine mezclando autorización, procesamiento HTTP, reglas de negocio y consultas. Para mantenerla legible, mostraremos el archivo en dos fragmentos consecutivos.
<?php
namespace local_coursefeedback\local;
defined('MOODLE_INTERNAL') || die();
final class feedback_repository {
public function get_for_user(int $courseid, int $userid): ?\stdClass {
global $DB;
$record = $DB->get_record('local_coursefeedback', [
'courseid' => $courseid,
'userid' => $userid,
]);
return $record ?: null;
}
public function save(
int $courseid,
int $userid,
int $rating,
string $comment
): int {
global $DB;
if ($rating < 1 || $rating > 5) {
throw new \coding_exception(
'The rating must be between 1 and 5.'
);
}
$now = time();
$existing = $this->get_for_user($courseid, $userid);
if ($existing) {
$existing->rating = $rating;
$existing->comment = trim($comment);
$existing->timemodified = $now;
$DB->update_record('local_coursefeedback', $existing);
return (int) $existing->id;
}
return (int) $DB->insert_record(
'local_coursefeedback',
(object) [
'courseid' => $courseid,
'userid' => $userid,
'rating' => $rating,
'comment' => trim($comment),
'timecreated' => $now,
'timemodified' => $now,
]
);
}A continuación, la misma clase añade la consulta de respuestas y el resumen que consumirá el informe. Este segundo fragmento continúa justo después del método save():
public function get_course_responses(int $courseid): array {
global $DB;
return $DB->get_records(
'local_coursefeedback',
['courseid' => $courseid],
'timemodified DESC',
'id, rating, comment, timemodified'
);
}
public function summarise(array $responses): array {
$total = count($responses);
$sum = 0;
foreach ($responses as $response) {
$sum += (int) $response->rating;
}
return [
'total' => $total,
'average' => $total > 0 ? $sum / $total : 0.0,
];
}
}La API DML de Moodle permite que este código funcione con las bases de datos soportadas sin escribir una variante para MySQL y otra para PostgreSQL. Cuando solo necesitamos operaciones simples, get_record(), insert_record() y update_record() son preferibles a construir SQL manual.
Además, la comprobación de la valoración aparece tanto en el formulario como en la clase. No es una duplicación accidental: el formulario protege su entrada y la clase defiende su propio contrato si mañana se invoca desde otra ruta.
Dos defensas, dos responsabilidades. El formulario devuelve un error comprensible al usuario. El repositorio impide que una llamada futura —por ejemplo, un servicio web o una tarea— guarde una valoración imposible. Validar solo en la pantalla convierte la regla de negocio en una sugerencia.
7. Procesar la valoración en index.php
Esta página muestra otro patrón recurrente al crear un plugin para Moodle: recibe el identificador del curso, carga el contexto, exige la capacidad y delega el formulario y la persistencia.
<?php
require_once(__DIR__ . '/../../config.php');
$courseid = required_param('courseid', PARAM_INT);
$course = get_course($courseid);
require_login($course);
$context = context_course::instance($courseid);
require_capability('local/coursefeedback:submit', $context);
$url = new moodle_url(
'/local/coursefeedback/index.php',
['courseid' => $courseid]
);
$PAGE->set_url($url);
$PAGE->set_context($context);
$PAGE->set_course($course);
$PAGE->set_pagelayout('incourse');
$PAGE->set_title(get_string('coursefeedback', 'local_coursefeedback'));
$PAGE->set_heading($course->fullname);
$repository = new \local_coursefeedback\local\feedback_repository();
$current = $repository->get_for_user($courseid, $USER->id);
$form = new \local_coursefeedback\form\feedback_form($url);
if ($data = $form->get_data()) {
$repository->save(
$courseid,
$USER->id,
(int) $data->rating,
(string) $data->comment
);
redirect(
$url,
get_string('feedbacksaved', 'local_coursefeedback'),
null,
\core\output\notification::NOTIFY_SUCCESS
);
}
if ($current) {
$form->set_data($current);
}
echo $OUTPUT->header();
echo $OUTPUT->heading(
get_string('coursefeedback', 'local_coursefeedback')
);
$form->display();
echo $OUTPUT->footer();Por tanto, el orden importa. Antes de leer o modificar datos hemos identificado el curso, iniciado la sesión y comprobado local/coursefeedback:submit en su contexto. La URL se construye con moodle_url y la respuesta termina con una redirección para evitar reenviar el formulario al recargar la página.
Form API incorpora la protección de clave de sesión en sus formularios. Eso no elimina la necesidad de validar el parámetro, verificar el curso y exigir la capacidad.

Checkpoint 2: la ruta de escritura ya está cerrada
Una petición válida recorre una secuencia completa: parámetro → curso → sesión → contexto → capacidad → formulario → validación → repositorio → DML → redirección. Si una nueva ruta permite guardar datos saltándose alguno de esos pasos, no es una alternativa equivalente: ha abierto otra frontera de seguridad.
8. Construir un informe protegido
report.php sigue la misma preparación de página, pero cambia la autorización:
$courseid = required_param('courseid', PARAM_INT);
$course = get_course($courseid);
require_login($course);
$context = context_course::instance($courseid);
require_capability(
'local/coursefeedback:viewreports',
$context
);
$repository = new \local_coursefeedback\local\feedback_repository();
$responses = $repository->get_course_responses($courseid);
$summary = $repository->summarise($responses);Asimismo, la selección de campos del repositorio es intencionada. Aunque la tabla contiene userid para garantizar una respuesta por persona, get_course_responses() no lo recupera. Después summarise() calcula el total y la media, y la página construye una html_table con la valoración, el comentario y la fecha.
$table = new html_table();
$table->head = [
get_string('rating', 'local_coursefeedback'),
get_string('comment', 'local_coursefeedback'),
get_string('lastmodified', 'local_coursefeedback'),
];
foreach ($responses as $response) {
$comment = trim((string) $response->comment);
$table->data[] = [
(int) $response->rating . '/5',
$comment !== ''
? format_text(
$comment,
FORMAT_PLAIN,
['context' => $context]
)
: get_string('nocomment', 'local_coursefeedback'),
userdate((int) $response->timemodified),
];
}
echo html_writer::table($table);FORMAT_PLAIN deja clara la decisión del ejemplo: el comentario no admite HTML enriquecido. Si utilizáramos un editor, necesitaríamos tratar el formato y los archivos asociados de otra forma, siguiendo las reglas de la Output API.
Minimiza desde la consulta. Omitir userid al renderizar es mejor que mostrarlo, pero no recuperarlo es aún más claro: la pantalla no recibe un dato que no necesita. La minimización empieza en la selección de campos.
9. Añadir enlaces sin llenar lib.php de lógica
Además, el plugin puede añadir enlaces contextuales cuando el usuario está dentro de un curso. El callback consulta las mismas capacidades que las páginas:
<?php
defined('MOODLE_INTERNAL') || die();
function local_coursefeedback_extend_navigation(
global_navigation $navigation
): void {
global $PAGE;
if (empty($PAGE->course) ||
(int) $PAGE->course->id === SITEID) {
return;
}
$courseid = (int) $PAGE->course->id;
$context = context_course::instance($courseid);
if (has_capability('local/coursefeedback:submit', $context)) {
$navigation->add(
get_string('coursefeedback', 'local_coursefeedback'),
new moodle_url(
'/local/coursefeedback/index.php',
['courseid' => $courseid]
),
navigation_node::TYPE_CUSTOM
);
}
if (has_capability(
'local/coursefeedback:viewreports',
$context
)) {
$navigation->add(
get_string('viewreport', 'local_coursefeedback'),
new moodle_url(
'/local/coursefeedback/report.php',
['courseid' => $courseid]
),
navigation_node::TYPE_CUSTOM
);
}
}Moodle carga muchos archivos lib.php. Por eso, como recomienda la documentación de plugins locales, debe actuar como puente: detectar el callback, realizar comprobaciones baratas y delegar cualquier lógica interna a clases autocargadas.
Un enlace no protege una página. Ocultar «Ver informe» a quien no tiene permiso mejora la experiencia, pero un usuario puede escribir la URL directamente. Por eso report.php vuelve a ejecutar require_capability(). La navegación orienta; la página autoriza.
10. No olvidar la privacidad
La tabla contiene un identificador de usuario y contenido escrito por esa persona. Por tanto, el plugin almacena datos personales y debe integrarse con la Privacy API de Moodle. Añadir una tabla y terminar el tutorial aquí dejaría fuera una responsabilidad importante.
La clase classes/privacy/provider.php debe cubrir tres grupos de operaciones:
- Metadatos: declarar qué tabla y campos contienen datos personales.
- Localización y exportación: encontrar los contextos de curso donde una persona tiene respuestas y exportarlas.
- Eliminación: borrar los datos de una persona, de varias personas aprobadas o de todo un contexto.
final class provider implements
\core_privacy\local\metadata\provider,
\core_privacy\local\request\plugin\provider,
\core_privacy\local\request\core_userlist_provider {
public static function get_metadata(
collection $collection
): collection {
$collection->add_database_table(
'local_coursefeedback',
[
'courseid' => 'privacy:metadata:courseid',
'userid' => 'privacy:metadata:userid',
'rating' => 'privacy:metadata:rating',
'comment' => 'privacy:metadata:comment',
'timecreated' => 'privacy:metadata:timecreated',
'timemodified' => 'privacy:metadata:timemodified',
],
'privacy:metadata:table'
);
return $collection;
}
// get_contexts_for_userid()
// get_users_in_context()
// export_user_data()
// delete_data_for_all_users_in_context()
// delete_data_for_user()
// delete_data_for_users()
}Por eso, el código completo acompaña al ejemplo. Aquí lo importante es entender el contrato: Moodle no puede exportar ni eliminar correctamente datos que un plugin guarda si ese plugin no explica dónde están y cómo tratarlos.
«El informe es anónimo» no significa «no hay datos personales». El plugin conserva userid para controlar la unicidad y permitir que la persona edite su respuesta. La privacidad se evalúa sobre los datos almacenados, no solo sobre lo que muestra una pantalla.

Checkpoint 3: lectura y gobierno ya son rutas distintas
En cambio, el informe responde a una necesidad docente y devuelve solo los campos necesarios. Privacy API responde a los derechos sobre los datos y sí puede localizar registros por usuario. Que ambas rutas consulten la misma tabla no significa que deban compartir propósito, permisos ni salida.
11. Crear un plugin para Moodle: instalarlo y comprobarlo
Necesitas un entorno desechable donde puedas instalar, actualizar y restaurar el componente sin miedo. Si aún no lo tienes, la guía para montar Moodle con Docker te ofrece una base local reproducible.
- Copia la carpeta del ejemplo en
local/coursefeedback. - Accede como administrador a «Administración del sitio → Notificaciones».
- Comprueba que Moodle detecta
local_coursefeedback. - Confirma la actualización para crear la tabla y registrar las capacidades.
- Purga las cachés.
- Entra en un curso con un estudiante y abre «Pulso del curso».
- Entra con un docente editor y abre el informe.
Durante el desarrollo, crear un plugin para Moodle también implica repetir instalaciones y actualizaciones. Desde consola podemos ejecutar:
php admin/cli/upgrade.php --non-interactive
php admin/cli/purge_caches.phpSi modificas db/access.php, db/install.xml o las definiciones de privacidad después de instalar, incrementa la versión cuando corresponda y ejecuta de nuevo la actualización. Borrar la tabla a mano no es un flujo de actualización.
12. Probar el comportamiento, no solo la pantalla
| Prueba | Resultado esperado |
|---|---|
| Acceso sin sesión | Moodle redirige al inicio de sesión |
| Usuario sin capacidad de envío | Recibe una excepción de permisos |
| Valoración 0, 6 o manipulada | La validación la rechaza |
| Comentario de más de 1000 caracteres | El servidor devuelve un error de formulario |
| Segundo envío del mismo usuario | Actualiza la fila; no crea otra |
Estudiante abre report.php | No puede consultar el informe |
| Docente editor abre el informe | Ve total, media y comentarios |
| Comentario con etiquetas HTML | Se trata como texto plano |
| Exportación de privacidad | Incluye valoración, comentario y fechas |
| Solicitud de borrado | Elimina las filas aprobadas |
La siguiente mejora al crear un plugin para Moodle sería automatizar esta matriz con PHPUnit y Behat. El repositorio permite probar inserción y actualización; el formulario puede probar su validación; las páginas y permisos encajan bien en escenarios Behat.

Checkpoint final: una prueba por cada decisión
La matriz no intenta comprobar que «la página se ve». Comprueba contratos: sesión, capacidad, rango, longitud, unicidad, minimización, exportación y borrado. Esa correspondencia entre decisión y prueba es la base de una suite automatizada útil.
Qué hemos separado y por qué
| Capa | Decisión | Evita |
|---|---|---|
| Página | Contexto, autorización y flujo HTTP | Accesos sin permiso y controladores gigantes |
| Formulario | Campos, limpieza y validación | Leer $_POST y repetir seguridad básica |
| Repositorio | Operaciones DML y regla de actualización | Consultas dispersas por varias pantallas |
| XMLDB | Esquema e índice único | Dependencia de un motor y duplicados |
| Capabilities | Autorización por contexto | Comprobar nombres de roles |
| Privacy API | Exportación y borrado | Datos invisibles para el sistema de privacidad |
No hace falta convertir un plugin de doce archivos en una arquitectura ceremonial. Sí conviene impedir que toda la funcionalidad termine en index.php. La separación elegida es pequeña, visible y fácil de extender.
Errores frecuentes al crear un plugin para Moodle
Al crear un plugin para Moodle, muchos fallos no aparecen como errores de PHP. La pantalla carga, pero la autorización es demasiado amplia, la regla de datos solo vive en el formulario o una actualización deja instalaciones antiguas en otro estado. Estas son las señales que conviene buscar:
- Confiar solo en
require_login(): autenticar no es autorizar. - Comprobar roles: un rol personalizado puede romper la lógica. Comprueba capacidades.
- Usar el contexto de sistema: perderías la posibilidad de asignar permisos por curso.
- Guardar dos respuestas y deduplicar después: expresa la unicidad en el esquema.
- Escribir SQL específico de MySQL: usa DML y los placeholders de Moodle.
- Mostrar directamente el comentario: limpia la entrada y formatea la salida.
- Guardar
useridy omitir Privacy API: el plugin almacena datos personales. - Introducir toda la lógica en
lib.php: Moodle lo carga con frecuencia. - Editar
install.xmlen una versión ya instalada: las instalaciones existentes necesitanupgrade.php.
Cómo evolucionar un plugin de Moodle sin perder el mapa
A partir de ahí, es tentador añadir diez funcionalidades a la vez. Sin embargo, resulta más útil evolucionar por capas y comprobar qué frontera cambia en cada una:
| Capa | Mejoras posibles | Decisión nueva |
|---|---|---|
| Control de participación | Fechas de apertura y cierre, activación por categorías o cursos, umbral mínimo antes de mostrar comentarios | Dónde vive la configuración y quién puede modificarla |
| Integración y operación | Exportación CSV, eventos al crear o actualizar, observador al eliminar un curso | Qué consumidores existen y cómo se conserva la consistencia |
| Presentación y volumen | Plantillas Mustache, filtros y paginación con table_sql | Cómo separar renderizado, consulta y rendimiento |
| Producto de encuestas | Preguntas configurables, varias escalas, moderación y análisis | Si la funcionalidad sigue siendo transversal o debe convertirse en mod_ |
| Calidad automatizada | PHPUnit, Behat, pruebas de privacidad y comprobaciones de estilo | Qué contratos deben bloquear una regresión |
Vuelve a preguntar «¿sigue siendo un plugin local?» Si cada docente necesita crear varias encuestas con configuración, fechas y resultados independientes, el dominio ya se parece a una actividad. Evolucionar también significa reconocer cuándo la primera decisión ha dejado de encajar.
Conclusión: crear un plugin para Moodle no empieza por el formulario
Crear un plugin para Moodle no consiste solo en colocar PHP dentro de local/. En cuanto la funcionalidad recibe datos aparecen decisiones sobre contexto, permisos, validación, persistencia, salida, actualización y privacidad.
Además, el «pulso del curso» es deliberadamente pequeño, pero recorre el circuito completo. Un estudiante envía una respuesta. Form API la valida. Una capacidad autoriza la acción en un curso concreto. El repositorio inserta o actualiza. XMLDB protege la unicidad. El docente utiliza otra lectura. Privacy API mantiene esos registros dentro del gobierno de datos de Moodle.
La lección al crear un plugin para Moodle no es que todo componente necesite exactamente estos doce archivos. Es que cada responsabilidad debe tener un lugar reconocible y cada regla importante debe existir en algo más sólido que la intención del desarrollador.
Qué conviene recordar: Moodle detecta carpetas con facilidad. Lo difícil —y lo verdaderamente reutilizable— es diseñar las fronteras del componente antes de que todo termine mezclado en una sola página.
Preguntas frecuentes sobre plugins de Moodle
Decisiones de diseño al crear un plugin para Moodle
¿Por qué no utilizar el módulo Feedback que ya incluye Moodle?
Para una necesidad real, primero deberías evaluar las funciones estándar y los plugins existentes. Aquí desarrollamos una versión mínima porque el objetivo es aprender las APIs y la estructura de un componente, no sustituir una herramienta madura. Si la actividad Feedback incluida en Moodle cubre el requisito, mantener menos código propio suele ser la mejor decisión.
¿Por qué guardar el usuario si el informe es anónimo?
Porque el ejemplo permite una sola respuesta por usuario y curso, y que cada persona la actualice. El informe no muestra la identidad, pero la tabla sí la conserva. Por eso implementamos Privacy API y evitamos afirmar que la recogida de datos es anónima.
¿Podría eliminar userid y hacer la encuesta completamente anónima?
Sí, pero cambiarían las reglas: no podrías garantizar de forma sencilla una única respuesta por persona ni permitir que se editara después. Además, un comentario libre puede contener información personal aunque no exista una columna userid. Anonimizar el esquema no garantiza que el contenido lo sea.
Integridad, actualizaciones y paso a producción
¿El índice único resuelve también dos envíos simultáneos?
Protege la integridad e impide que queden dos filas para la misma pareja de curso y usuario. Sin embargo, dos inserciones casi simultáneas podrían hacer que una recibiera una excepción por clave duplicada. Un plugin de producción debería decidir cómo tratar esa carrera —por ejemplo, capturando el conflicto y reintentando como actualización— y cubrirla con una prueba.
¿Debo escribir install.xml a mano?
No es lo recomendable. Utiliza el editor XMLDB de Moodle: reduce errores de formato y ayuda a generar las definiciones necesarias para futuras actualizaciones. El XML del artículo sirve para entender el resultado, no para sustituir la herramienta.
¿Cuándo necesito db/upgrade.php?
Cuando una versión ya instalada debe pasar de un esquema anterior a uno nuevo. install.xml describe el estado final para instalaciones nuevas; upgrade.php aplica pasos incrementales sobre instalaciones existentes. La guía de actualización de plugins detalla este ciclo: cambiar solo install.xml no actualiza una tabla que ya fue creada.
¿Es este plugin apto para producción?
Es un ejemplo educativo funcional. Antes de desplegarlo deberías adaptar requisitos, añadir pruebas automatizadas, revisar accesibilidad y privacidad, definir retención, resolver carreras de escritura, gestionar la eliminación de cursos y ejecutar Moodle Code Checker y las comprobaciones de compatibilidad que correspondan a tu entorno.
Documentación oficial para desarrollar plugins de Moodle
- Plugins locales de Moodle.
- Nombres de componente Frankenstyle y archivos comunes de los plugins.
- Forms API y Access API.
- Editor XMLDB e instalación y actualización de plugins.
- Data manipulation API y Output API.
- Privacy API.
- Pruebas con PHPUnit y escenarios Behat para plugins.
- Moodle Code Checker.
Tu siguiente paso: prueba el circuito completo
Instala el ejemplo en un entorno local, ejecuta la matriz con dos usuarios y modifica una sola regla —por ejemplo, el rango de valoración o la longitud del comentario— en todas las capas que la protegen. Ese ejercicio enseña más que añadir otra pantalla copiando código.


Deja una respuesta