Help us improve
Share bugs, ideas, or general feedback.
From moodle-dev
Guides Moodle plugin development: version.php, DB install/upgrade, capabilities, web services, PSR-4 autoloading, hooks, settings, privacy provider, and coding standards.
npx claudepluginhub saadrahman01/claude-moodle-dev --plugin moodle-devHow this skill is triggered — by the user, by Claude, or both
Slash command
/moodle-dev:moodle-plugin-developmentThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Moodle plugins follow strict frankenstyle naming and a fixed file layout. Every plugin lives under a type-specific directory (`local/`, `mod/`, `blocks/`, `course/format/`, `theme/`, `auth/`, `enrol/`, `report/`, `question/type/`, `filter/`, `repository/`, etc.) and is identified by `<type>_<name>` (e.g., `local_school`, `mod_quiz`, `format_schoolgram`).
Upgrades Moodle plugins across major versions: replaces deprecated APIs (print_error, add_to_log, formslib), migrates to Moodle 4.x/5.x conventions (Hooks, PSR-4, /public doc-root, Routing Engine), and bumps PHP 8.1–8.4 compatibility with upgrade.txt notes.
Guides creation of custom external web service APIs for Moodle LMS using the external API framework and PHP coding standards. For plugins, REST/AJAX endpoints, quizzes, and mobile backends.
Develops WordPress plugins with structure patterns, hooks, security (nonces, sanitization, prepared $wpdb queries), REST API, custom post types, and Settings API.
Share bugs, ideas, or general feedback.
Moodle plugins follow strict frankenstyle naming and a fixed file layout. Every plugin lives under a type-specific directory (local/, mod/, blocks/, course/format/, theme/, auth/, enrol/, report/, question/type/, filter/, repository/, etc.) and is identified by <type>_<name> (e.g., local_school, mod_quiz, format_schoolgram).
Core principle: Moodle code must declare defined('MOODLE_INTERNAL') || die(); at top of every PHP file (except classes under classes/ autoloaded via PSR-4), use $DB for all DB access, use get_string() for user-facing text, and bump version.php for any DB or capability change.
local/, mod/, blocks/, course/format/, theme/, etc.db/install.xml + db/upgrade.phpdb/access.phpdb/services.php + classes/external/db/tasks.php + classes/task/db/events.php + classes/event/settings.phplang/<lang>/<component>.phpversion.php after schema/capability changeSkip when: working purely on frontend AMD modules without backend changes, or non-Moodle PHP code.
Minimum files for a local_<name> plugin:
local/<name>/
version.php # required: component, version, requires, maturity
lang/en/local_<name>.php # required: 'pluginname' string
lib.php # optional: hooks (extend_navigation, pluginfile, etc.)
settings.php # optional: admin settings page
db/
install.xml # DB schema (edit via /admin/tool/xmldb/)
upgrade.php # versioned upgrade steps
access.php # capabilities
services.php # web service definitions
tasks.php # scheduled tasks
events.php # event observers
caches.php # cache definitions
classes/ # PSR-4: \local_<name>\foo\bar -> classes/foo/bar.php
external/ # external (web service) functions
task/ # scheduled task classes
event/ # custom events
privacy/provider.php # GDPR provider (REQUIRED)
templates/ # Mustache templates
amd/src/ # ES modules (built via grunt -> amd/build/)
tests/ # PHPUnit + Behat
<?php
defined('MOODLE_INTERNAL') || die();
$plugin->component = 'local_example'; // frankenstyle, must match dir
$plugin->version = 2026042500; // YYYYMMDDXX, bump on any db/capability change
$plugin->requires = 2024100700; // min Moodle version (4.5 LTS); use 2025041400 for 5.0+, 2025100600 for 5.1+, 2026042000 for 5.2+
$plugin->release = '1.0.0';
$plugin->maturity = MATURITY_STABLE; // ALPHA | BETA | RC | STABLE
$plugin->dependencies = ['mod_quiz' => 2024100700]; // optional
install.xml via Moodle XMLDB editor (Site admin -> Development -> XMLDB editor) — never hand-edit.$plugin->version in version.phpdb/upgrade.php keyed on old version:function xmldb_local_example_upgrade($oldversion) {
global $DB;
$dbman = $DB->get_manager();
if ($oldversion < 2026042500) {
$table = new xmldb_table('local_example_items');
$field = new xmldb_field('status', XMLDB_TYPE_INTEGER, '4', null,
XMLDB_NOTNULL, null, '0', 'name');
if (!$dbman->field_exists($table, $field)) {
$dbman->add_field($table, $field);
}
upgrade_plugin_savepoint(true, 2026042500, 'local', 'example');
}
return true;
}
$capabilities = [
'local/example:view' => [
'captype' => 'read',
'contextlevel' => CONTEXT_SYSTEM,
'archetypes' => [
'user' => CAP_ALLOW,
'editingteacher' => CAP_ALLOW,
],
],
];
Runtime check: require_capability('local/example:view', $context); or has_capability(...).
$functions = [
'local_example_get_items' => [
'classname' => 'local_example\external\get_items',
'methodname' => 'execute',
'description' => 'Return items',
'type' => 'read',
'ajax' => true,
'capabilities' => 'local/example:view',
],
];
External class extends \core_external\external_api (Moodle 4.2+; older = external_api) and defines execute_parameters(), execute(), execute_returns().
lang/en/local_example.php:
$string['pluginname'] = 'Example';
$string['example:view'] = 'View example'; // capability strings: <plugin>:<cap>
$string['greeting'] = 'Hello, {$a->name}'; // placeholders
Use: get_string('greeting', 'local_example', ['name' => $user->firstname]);
$DBglobal $DB;
$DB->get_record('local_example_items', ['id' => $id], '*', MUST_EXIST);
$DB->get_records_sql('SELECT * FROM {local_example_items} WHERE status = ?', [1]);
$DB->insert_record('local_example_items', $obj);
$DB->update_record('local_example_items', $obj);
$DB->delete_records('local_example_items', ['id' => $id]);
Never mysqli_* / PDO. Always use {tablename} placeholder (Moodle prefixes). Always bind params, never concatenate.
Common Moodle callbacks (function name = <component>_<hookname>):
local_example_extend_navigation(global_navigation $nav) — add nav nodeslocal_example_before_http_headers() — runs before headerslocal_example_extend_settings_navigation($settingsnav, $context)local_example_pluginfile($course, $cm, $context, $filearea, $args, $forcedl, $options) — serve file areaMoodle 4.4+: prefer the new hooks API (\core\hook\manager) over magic callbacks where available.
$OUTPUT->render_from_template('local_example/foo', $data) (Mustache: templates/foo.mustache)classes/output/renderer.php extends plugin_renderer_baseecho raw HTML in business logic; always go through renderer or templateEvery plugin must declare privacy. Minimum (no user data stored):
// classes/privacy/provider.php
namespace local_example\privacy;
defined('MOODLE_INTERNAL') || die();
class provider implements \core_privacy\local\metadata\null_provider {
public static function get_reason(): string {
return 'privacy:metadata';
}
}
If plugin stores user data, implement \core_privacy\local\request\plugin\provider and define get_metadata, export_user_data, delete_data_for_user_in_context, delete_data_for_users, get_contexts_for_userid, get_users_in_context.
| Task | File | Bump version? |
|---|---|---|
| Add DB table/field | db/install.xml + db/upgrade.php | Yes |
| Add capability | db/access.php + lang string | Yes |
| Add web service fn | db/services.php + classes/external/ | Yes |
| Add scheduled task | db/tasks.php + classes/task/ | Yes |
| Add event observer | db/events.php | Yes |
| Add cache definition | db/caches.php | Yes |
| Add string | lang/en/<component>.php | No |
| Add settings | settings.php | No |
| Add template | templates/*.mustache | No |
| Add renderer | classes/output/renderer.php | No |
<?php on line 1, no closing ?> at end of pure PHP filesdefined('MOODLE_INTERNAL') || die(); immediately after license block (skip in classes/ PSR-4 files)vendor/bin/phpcs --standard=moodle <path> before commit (local_codechecker plugin or moodle-cs ruleset)get_string(), never hard-coded English| Mistake | Fix |
|---|---|
Hand-editing install.xml | Use XMLDB editor at /admin/tool/xmldb/ |
Forgetting to bump version.php after schema change | Bump + add upgrade.php step + upgrade_plugin_savepoint |
Using raw SQL with {} quoting wrong | Use {tablename} and ? / named params, never string concat |
MOODLE_INTERNAL check inside classes/ PSR-4 file | Remove — autoloaded files don't need it |
| Storing user data without privacy provider | Implement \core_privacy\local\request\plugin\provider |
| Hard-coded English strings in PHP/Mustache | Move to lang/en/<component>.php + get_string / {{#str}} |
Forgetting require_capability() on entry points | Always require_login() + capability check early |
Not purging caches after version.php bump | php admin/cli/upgrade.php or Site admin -> Purge caches |
Direct $_GET/$_POST access | Use required_param() / optional_param() with type |
Missing sesskey() check on state-changing requests | Call require_sesskey() on POST handlers |
| Type | Dir | Frankenstyle | Extra required files |
|---|---|---|---|
| Local | local/<name> | local_<name> | none beyond skeleton |
| Activity module | mod/<name> | mod_<name> | mod_form.php, view.php, lib.php w/ <name>_add_instance etc. |
| Block | blocks/<name> | block_<name> | block_<name>.php extends block_base |
| Course format | course/format/<name> | format_<name> | format.php, lib.php extends core_courseformat\base |
| Theme | theme/<name> | theme_<name> | config.php, scss/, layout/ |
| Auth | auth/<name> | auth_<name> | auth.php extends auth_plugin_base |
| Enrol | enrol/<name> | enrol_<name> | lib.php extends enrol_plugin |
| Question type | question/type/<name> | qtype_<name> | questiontype.php, question.php, renderer.php |
| Filter | filter/<name> | filter_<name> | filter.php extends moodle_text_filter |
| Repository | repository/<name> | repository_<name> | lib.php extends repository |
require_login() (and require_capability()) at top of every entry scriptrequire_sesskey() on every state-changing POSTrequired_param($name, PARAM_INT) / optional_param(...) — never raw $_REQUEST$DB->... with placeholders — no SQL concats(), format_string(), format_text()pluginfile.php + <component>_pluginfile() callback, never direct pathphp admin/cli/upgrade.php --non-interactive # apply pending upgrades
php admin/cli/purge_caches.php # clear all caches
php admin/tool/behat/cli/init.php # init behat tests
vendor/bin/phpunit --testsuite local_example_testsuite
vendor/bin/phpcs --standard=moodle local/example
tests/<thing>_test.php extending advanced_testcase, use $this->resetAfterTest(), generators via self::getDataGenerator()->get_plugin_generator('local_example')tests/behat/*.feature with @local_example tag, step definitions in tests/behat/behat_local_example.php