Install
$ agentstack add skill-saadrahman01-claude-moodle-dev-moodle-plugin-development ✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
About
Moodle Plugin Development
Overview
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 _ (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.
When to Use
- Creating new plugin under
local/,mod/,blocks/,course/format/,theme/, etc. - Adding/modifying DB tables →
db/install.xml+db/upgrade.php - Adding capabilities →
db/access.php - Adding web services / external functions →
db/services.php+classes/external/ - Adding scheduled tasks →
db/tasks.php+classes/task/ - Adding events / observers →
db/events.php+classes/event/ - Adding admin settings page →
settings.php - Translating strings →
lang//.php - Reviewing PR for Moodle coding-style compliance
- Bumping
version.phpafter schema/capability change
Skip when: working purely on frontend AMD modules without backend changes, or non-Moodle PHP code.
Plugin Skeleton
Minimum files for a local_ plugin:
local//
version.php # required: component, version, requires, maturity
lang/en/local_.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_\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
version.php Template
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
db/install.xml + upgrade.php
- Edit
install.xmlvia Moodle XMLDB editor (Site admin -> Development -> XMLDB editor) — never hand-edit. - Every schema change requires:
- Bump
$plugin->versioninversion.php - Add upgrade step in
db/upgrade.phpkeyed on old version:
function xmldb_local_example_upgrade($oldversion) {
global $DB;
$dbman = $DB->get_manager();
if ($oldversion field_exists($table, $field)) {
$dbman->add_field($table, $field);
}
upgrade_plugin_savepoint(true, 2026042500, 'local', 'example');
}
return true;
}
db/access.php Capabilities
$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(...).
db/services.php (Web Services / AJAX)
$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 Strings
lang/en/local_example.php:
$string['pluginname'] = 'Example';
$string['example:view'] = 'View example'; // capability strings: :
$string['greeting'] = 'Hello, {$a->name}'; // placeholders
Use: get_string('greeting', 'local_example', ['name' => $user->firstname]);
DB Access — Always $DB
global $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.
Hooks via lib.php
Common Moodle callbacks (function name = _):
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 area
Moodle 4.4+: prefer the new hooks API (\core\hook\manager) over magic callbacks where available.
Output — Renderers + Templates
- HTML via
$OUTPUT->render_from_template('local_example/foo', $data)(Mustache:templates/foo.mustache) - Custom renderer:
classes/output/renderer.phpextendsplugin_renderer_base - Never
echoraw HTML in business logic; always go through renderer or template
Privacy (GDPR) — Required
Every 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.
Quick Reference
| 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/.php | No | | Add settings | settings.php | No | | Add template | templates/*.mustache | No | | Add renderer | classes/output/renderer.php | No |
Coding Standards
- 4-space indent, no tabs
- Opening `` at end of pure PHP files
defined('MOODLE_INTERNAL') || die();immediately after license block (skip inclasses/PSR-4 files)- snake_case for functions/vars, PascalCase for classes
- Run
vendor/bin/phpcs --standard=moodlebefore commit (local_codecheckerplugin ormoodle-csruleset) - All user-facing strings via
get_string(), never hard-coded English - GPL-3.0-or-later license header on every PHP file
Common Mistakes
| 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/.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 |
Plugin Type Cheatsheet
| Type | Dir | Frankenstyle | Extra required files | |------|-----|--------------|----------------------| | Local | local/ | local_ | none beyond skeleton | | Activity module | mod/ | mod_ | mod_form.php, view.php, lib.php w/ _add_instance etc. | | Block | blocks/ | block_ | block_.php extends block_base | | Course format | course/format/ | format_ | format.php, lib.php extends core_courseformat\base | | Theme | theme/ | theme_ | config.php, scss/, layout/ | | Auth | auth/ | auth_ | auth.php extends auth_plugin_base | | Enrol | enrol/ | enrol_ | lib.php extends enrol_plugin | | Question type | question/type/ | qtype_ | questiontype.php, question.php, renderer.php | | Filter | filter/ | filter_ | filter.php extends moodle_text_filter | | Repository | repository/ | repository_ | lib.php extends repository |
Security Checklist
require_login()(andrequire_capability()) at top of every entry scriptrequire_sesskey()on every state-changing POSTrequired_param($name, PARAM_INT)/optional_param(...)— never raw$_REQUEST- Use
$DB->...with placeholders — no SQL concat - Escape output:
s(),format_string(),format_text() - File serving via
pluginfile.php+_pluginfile()callback, never direct path
CLI Helpers
php 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
Testing
- PHPUnit:
tests/_test.phpextendingadvanced_testcase, use$this->resetAfterTest(), generators viaself::getDataGenerator()->get_plugin_generator('local_example') - Behat:
tests/behat/*.featurewith@local_exampletag, step definitions intests/behat/behat_local_example.php
References
- Moodle Dev Docs: https://moodledev.io
- Plugin types: https://moodledev.io/docs/apis/plugintypes
- Coding style: https://moodledev.io/general/development/policies/codingstyle
- XMLDB: https://moodledev.io/docs/apis/core/dml/xmldb
- Privacy API: https://moodledev.io/docs/apis/subsystems/privacy
- Hooks API (4.4+): https://moodledev.io/docs/apis/core/hooks
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: SaadRahman01
- Source: SaadRahman01/claude-moodle-dev
- License: MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.