Moodle is one of the largest Learning Management System (LMS) platforms in the world, and one of its quietly excellent superpowers is its plugin architecture. Every feature you can add — a new activity, a block, a report, an authentication method, a scheduled background job — is a plugin, and every plugin follows the same handful of conventions. Once you know them, the framework gets out of your way. 🐘
What “Creating a New Module” Means in Moodle
Moodle has many plugin types, but the four most common ones you’ll touch as a backend developer are:
- mod/ — activity modules (e.g. mod/quiz, mod/forum). Things users add to courses.
- local/ — local plugins. Custom business logic that doesn’t fit any other plugin type. Most internal company plugins live here.
- blocks/ — sidebar blocks shown on pages.
- admin/tool/ — admin-side utilities and integrations.
The walkthrough below uses a local plugin called local_attendance, because local/ is the most common entry point for in-house development and it has all the same bones as the other types.
The Minimum File Layout
A working Moodle plugin needs surprisingly little. Drop this under html/local/attendance/:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | local/attendance/ ├── version.php ← required: identifies the plugin and its current version ├── lang/ │ └── en/ │ └── local_attendance.php ← language strings (required even for English) ├── db/ │ ├── install.xml ← schema for new installs │ ├── install.php ← one-time seed logic after install.xml runs (optional) │ ├── upgrade.php ← version-gated migrations (optional but you'll want it) │ ├── tasks.php ← scheduled task registrations (optional) │ └── access.php ← capabilities/permissions (optional) └── classes/ └── task/ └── send_reminders.php ← a sample scheduled task |
That’s it. Visit Site administration in your browser and Moodle will detect the new plugin, run install.xml, run install.php, and register it. No composer, no separate migration tool, no build step. ✨
version.php — the Heartbeat of Every Plugin
This file is small but load-bearing. Moodle uses it to decide whether to upgrade, downgrade-error, or do nothing on every page load:
1 2 3 4 5 6 7 8 9 10 11 12 | <?php defined('MOODLE_INTERNAL') || die(); $plugin->component = 'local_attendance'; // frankenstyle name — type_name $plugin->version = 2024100100; // YYYYMMDDXX format $plugin->requires = 2023100900; // minimum Moodle core version $plugin->maturity = MATURITY_STABLE; // ALPHA | BETA | RC | STABLE $plugin->release = 'v1.0.0'; $plugin->dependencies = [ 'mod_quiz' => 2023100900, // optional: other plugins we depend on ]; |
The version number is a date-stamp integer in YYYYMMDDXX format — the XX is a daily counter, so multiple bumps on the same day still produce monotonically increasing values. Bumping this number is what triggers upgrade.php to run. If you forget to bump it, your migration code never executes.
If you bump it too aggressively and then have to roll back code, Moodle will refuse to start with a Cannot downgrade plugin from X to Y error. There’s no built-in rollback — you either roll forward (bump again) or manually edit mdl_config_plugins to lie about the installed version. More on that below.
db/install.xml — Your Schema, Declared Once
Moodle has its own XMLDB schema format. You don’t write raw SQL DDL; you write XML, and Moodle generates the right SQL for whatever database backend you’re running (MariaDB, MySQL, PostgreSQL, MSSQL, Oracle). Use the built-in editor at Site administration → Development → XMLDB editor to generate the file — hand-writing it is technically possible but error-prone.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | <?xml version="1.0" encoding="UTF-8" ?> <XMLDB PATH="local/attendance/db" VERSION="20241001" COMMENT="local_attendance"> <TABLES> <TABLE NAME="local_attendance_entries" COMMENT="One row per check-in"> <FIELDS> <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/> <FIELD NAME="userid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/> <FIELD NAME="courseid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/> <FIELD NAME="status" TYPE="char" LENGTH="20" NOTNULL="true" SEQUENCE="false"/> <FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/> </FIELDS> <KEYS> <KEY NAME="primary" TYPE="primary" FIELDS="id"/> </KEYS> <INDEXES> <INDEX NAME="userid_courseid_ix" UNIQUE="false" FIELDS="userid, courseid"/> </INDEXES> </TABLE> </TABLES> </XMLDB> |
The table gets prefixed automatically — if your site uses $CFG->prefix = ‘mdl_’, the actual table is mdl_local_attendance_entries. install.xml only runs once, the first time the plugin is installed. After that, every schema change has to go through upgrade.php. ⚠️
db/install.php — Seeding Data
If you need to seed data after the schema is created (default categories, sample rows, admin-visible config), do it here:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | <?php defined('MOODLE_INTERNAL') || die(); function xmldb_local_attendance_install() { global $DB; // Seed a couple of default attendance statuses $defaults = [ ['shortname' => 'present', 'description' => 'Present'], ['shortname' => 'late', 'description' => 'Late'], ['shortname' => 'absent', 'description' => 'Absent'], ]; foreach ($defaults as $row) { $DB->insert_record('local_attendance_statuses', (object) $row); } } |
Like install.xml, this runs once on first install and never again. If you need to seed data on a site that’s already running, do it from upgrade.php instead.
db/upgrade.php — Version-Gated Migrations
This is where the long-term life of your plugin happens. Every schema or data change after the initial install lives behind a version gate:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | <?php defined('MOODLE_INTERNAL') || die(); function xmldb_local_attendance_upgrade($oldversion) { global $DB; $dbman = $DB->get_manager(); if ($oldversion < 2024100200) { // Add a new 'notes' column to the entries table $table = new xmldb_table('local_attendance_entries'); $field = new xmldb_field('notes', XMLDB_TYPE_TEXT, null, null, null, null, null, 'status'); if (!$dbman->field_exists($table, $field)) { $dbman->add_field($table, $field); } upgrade_plugin_savepoint(true, 2024100200, 'local', 'attendance'); } if ($oldversion < 2024100300) { // Backfill a default value for any existing rows $DB->execute("UPDATE {local_attendance_entries} SET notes = '' WHERE notes IS NULL"); upgrade_plugin_savepoint(true, 2024100300, 'local', 'attendance'); } return true; } |
Two things to know:
- Every block must end with upgrade_plugin_savepoint(). This is what tells Moodle “this migration succeeded, don’t run it again” — even if a later block fails. Without it, a partial failure leaves the plugin in a wedged state on the next upgrade attempt.
- Migrations are not transactional across blocks. If block A succeeds and block B fails, A’s changes stay applied. Design each block to be independently completable.
Trigger the upgrade after bumping version.php:
1 2 | php admin/cli/upgrade.php --non-interactive php admin/cli/purge_caches.php |
About “Rollback”
Moodle does not have a rollback command. There’s no upgrade.php down() equivalent, no Rails-style db:rollback. The Moodle philosophy is roll-forward: if you applied a bad migration, write a new migration with a higher version number that reverses or corrects the change.
When you really need to back out — for example, you’re switching branches and the new branch’s code is older than the DB — your options are:
- Restore a DB dump taken before the upgrade. This is what production-grade teams do.
- Manually edit mdl_config_plugins to set the plugin’s version back. Risky and only safe if you know exactly what changed:
1 2 3 | UPDATE mdl_config_plugins SET VALUE = '2024100100' WHERE plugin = 'local_attendance' AND name = 'version'; |
The honest answer: in development, you restore dumps. In production, you only ever roll forward. 🛡️
db/tasks.php — Scheduled Jobs
Anything cron-like in Moodle is a scheduled task. Register it in tasks.php:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
The cron fields take the standard cron syntax. The class itself lives at classes/task/send_reminders.php:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | <?php namespace local_attendance\task; defined('MOODLE_INTERNAL') || die(); class send_reminders extends \core\task\scheduled_task { public function get_name(): string { return get_string('task_send_reminders', 'local_attendance'); } public function execute() { global $DB; mtrace('Looking for users to remind...'); $count = $DB->count_records('local_attendance_entries', ['status' => 'absent']); mtrace("Found {$count} absent entries."); // ... send emails, etc. } } |
Once tasks.php is in place and version.php is bumped, the next upgrade run registers the task. You can also run it manually for testing:
1 | php admin/cli/scheduled_task.php --execute='\local_attendance\task\send_reminders' |
For one-off background jobs (not on a recurring schedule), use adhoc tasks — same idea, but you queue them programmatically and they run once.
Putting It All Together
The lifecycle of a Moodle plugin, in five lines:
- Create version.php, install.xml, and a language file → first install creates the tables.
- Need to seed defaults? Add install.php.
- Change the schema later? Bump version.php and add a block to upgrade.php.
- Need background work? Add tasks.php and a task class under classes/task/.
- Run admin/cli/upgrade.php. Roll forward, never back.
Once you internalize that loop, building Moodle plugins stops feeling like CMS development and starts feeling like writing well-organized PHP modules with a particularly opinionated framework underneath. 💡
Further Reading
- Moodle Developer Resources (canonical docs) — moodledev.io. The new home of Moodle’s developer documentation since 2022; the source of truth for plugin types, APIs, and conventions.
- Plugin types reference — moodledev.io/docs/apis/plugintypes. The exhaustive list of plugin types with required files for each.
- XMLDB editor guide — moodledev.io/general/development/tools/xmldb. The official guide to the in-Moodle XML schema editor. Never hand-write install.xml.
- Scheduled tasks API — moodledev.io/docs/apis/subsystems/task. Scheduled vs adhoc tasks, cron field syntax, locking, and failure handling.
- Upgrade API — moodledev.io/docs/apis/core/upgrade. The official guide to upgrade.php, savepoints, and version handling.
- Moodle coding style — moodledev.io/general/development/policies/codingstyle. PHPDoc, naming, and the conventions code reviewers will check.
- moodle-plugin-ci — github.com/moodlehq/moodle-plugin-ci. The continuous integration tooling Moodle HQ uses to validate plugins; great to wire up to your own repo for automatic lint, unit tests, and Behat runs.
Happy plugin-building. 🛠️