A Moodle local plugin is a folder under public/local/ (Moodle 5.1 and later) with at least a version.php and a language file. Moodle detects it on the next visit to Site administration > Notifications, installs it, and from then on you can add settings, capabilities, pages, navigation links, event observers, scheduled tasks and web services to it. This moodle local plugin tutorial builds a small but complete plugin from scratch, one file at a time, so you can see what each file does and why.
The example is local_helpdesk: a course-level "Help desk" page that shows a support message and contact address set by the site administrator, protected by its own capability and linked from course navigation. It is deliberately small, but it uses the same building blocks as a production plugin.
Checked against the Moodle 5.3.0 source (version 2026100500) and the Moodle developer documentation for local plugins and version.php. The code was not run on a live site; test on a development site first, never on production.
When a local plugin is the right choice
Moodle's own guidance is to use a standard plugin type (activity, block, authentication, enrolment, report and so on) whenever one fits, and to fall back to local for things that do not. Typical local plugins are:
- event observers that talk to external systems, such as an HR system or CRM,
- custom web service functions,
- site-wide admin settings and system-level features,
- extra items in course or site navigation.
Local plugins also behave a little differently: they are installed and upgraded last, they are loaded last when Moodle builds the admin tree (so they can add settings to any page), and they do not need any user interface at all. If you are not sure which type you need, our Moodle plugin development page compares the common types.
Before you start
- A development copy of Moodle 5.3 (or 5.1 or later) that you can break.
- Developer debugging on, so mistakes show up immediately. In
config.phpat the project root, set$CFG->debug = (E_ALL);and$CFG->debugdisplay = 1;. The comments inconfig-dist.phpmark both as not for production. - Shell access, so you can run the CLI scripts in
admin/cli/.
Since Moodle 5.1, web-accessible code lives in a public/ directory, while config.php and admin/cli/ stay in the project root. So the plugin goes in public/local/helpdesk, and CLI commands are run from the project root, for example php admin/cli/upgrade.php. On Moodle 5.0 or earlier, drop the public/ prefix. The finished plugin has this layout:
public/local/helpdesk/
version.php
lang/en/local_helpdesk.php
settings.php
db/access.php
index.php
lib.php
classes/privacy/provider.php
Step 1: version.php
Every plugin needs version.php. Moodle reads it to decide whether to install or upgrade the plugin and whether it is compatible with the site.
<?php
// This file is part of Moodle - https://moodle.org/
// (Add the standard GPL v3 or later licence header here.)
/**
* Version details for local_helpdesk.
*
* @package local_helpdesk
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
$plugin->component = 'local_helpdesk';
$plugin->version = 2027030700;
$plugin->requires = 2025100600; // Moodle 5.1.0.
$plugin->supported = [501, 503];
$plugin->maturity = MATURITY_ALPHA;
$plugin->release = '0.1.0';
componentis the full frankenstyle name, plugin type plus folder name. Moodle uses it to check that the code sits in the right folder.versionisYYYYMMDDXX. Increase it every time you change something Moodle must notice: capabilities, settings structure, database schema, observers or cached callbacks.requiresis the minimum Moodle core version. 2025100600 is Moodle 5.1.0, the first release with thepublic/layout.supportedlists the lowest and highest Moodle branches you support, inclusive. Only claim branches you have tested.
The developer docs note that version.php must never contain require_once; keep it to these assignments. Moodle's coding style also expects the GPL boilerplate and a docblock with @package local_helpdesk at the top of every PHP file. The remaining examples leave them out to save space, so add them back in your own files.
Step 2: language strings, then install
Every component needs at least a pluginname string. Create lang/en/local_helpdesk.php:
<?php
defined('MOODLE_INTERNAL') || die();
$string['pluginname'] = 'Help desk';
$string['helpdesk:view'] = 'View the course help desk page';
$string['message'] = 'Support message';
$string['message_desc'] = 'Text shown at the top of the help desk page in every course.';
$string['email'] = 'Support email';
$string['email_desc'] = 'Address learners should write to for help.';
$string['contact'] = 'Contact: {$a}';
$string['privacy:metadata'] = 'The Help desk plugin does not store any personal data.';
The capability string key is the capability name without the local/ prefix. Keep every user-facing word in this file, so the plugin can be translated through the standard language packs.
Install the plugin
With just these two files in place, the plugin is installable. Either log in as an administrator and open Site administration > Notifications, or run the upgrade from the project root:
php admin/cli/upgrade.php --non-interactive
The plugin then appears under Site administration > Plugins > Plugins overview. From here on, every time you add or change a capability, an observer or a callback, bump $plugin->version and run the upgrade again. For small code changes that do not need an upgrade, php admin/cli/purge_caches.php clears cached plugin data such as the list of plugin callbacks.
Step 3: admin settings
For most plugin types Moodle creates a settings page for you. For local plugins it does not: $settings is not defined, so you create a page and add it to the Local plugins category yourself. Create settings.php:
<?php
defined('MOODLE_INTERNAL') || die();
if ($hassiteconfig) {
$settings = new admin_settingpage('local_helpdesk', get_string('pluginname', 'local_helpdesk'));
$ADMIN->add('localplugins', $settings);
$settings->add(new admin_setting_configtextarea(
'local_helpdesk/message',
get_string('message', 'local_helpdesk'),
get_string('message_desc', 'local_helpdesk'),
'',
PARAM_TEXT
));
$settings->add(new admin_setting_configtext(
'local_helpdesk/email',
get_string('email', 'local_helpdesk'),
get_string('email_desc', 'local_helpdesk'),
'',
PARAM_EMAIL
));
}
The local_helpdesk/message form of the name stores the value in the plugin's own config namespace, which you read back with get_config('local_helpdesk', 'message'). The $hassiteconfig check keeps the page away from users who cannot configure the site. The page appears under Site administration > Plugins > Local plugins. In Moodle 5.3 the admin setting classes live in core_admin, but the legacy class names used here still load.
Step 4: a capability and a page
Define who may see the page in db/access.php:
<?php
defined('MOODLE_INTERNAL') || die();
$capabilities = [
'local/helpdesk:view' => [
'captype' => 'read',
'contextlevel' => CONTEXT_COURSE,
'archetypes' => [
'student' => CAP_ALLOW,
'teacher' => CAP_ALLOW,
'editingteacher' => CAP_ALLOW,
'manager' => CAP_ALLOW,
],
],
];
Capabilities are installed when the version changes, so bump version.php and run the upgrade. Note the developer docs' warning: changes to an existing capability's defaults only apply to new installations, so get the archetypes right early.
Now the page itself, index.php:
<?php
require(__DIR__ . '/../../config.php');
$courseid = required_param('id', PARAM_INT);
$course = get_course($courseid);
require_login($course);
$context = \core\context\course::instance($course->id);
require_capability('local/helpdesk:view', $context);
$PAGE->set_url(new moodle_url('/local/helpdesk/index.php', ['id' => $course->id]));
$PAGE->set_context($context);
$PAGE->set_pagelayout('incourse');
$PAGE->set_title(get_string('pluginname', 'local_helpdesk'));
$PAGE->set_heading(format_string($course->fullname));
$message = (string) get_config('local_helpdesk', 'message');
$email = (string) get_config('local_helpdesk', 'email');
echo $OUTPUT->header();
echo $OUTPUT->heading(get_string('pluginname', 'local_helpdesk'));
if ($message !== '') {
echo $OUTPUT->box(format_text($message, FORMAT_PLAIN));
}
if ($email !== '') {
echo html_writer::tag('p', get_string('contact', 'local_helpdesk', obfuscate_mailto($email)));
}
echo $OUTPUT->footer();
A few points that matter in every Moodle page:
require(__DIR__ . '/../../config.php')resolves topublic/config.phpin 5.1 and later, a small loader that includes the realconfig.phpone level up.required_param()with aPARAM_*type cleans input. Never read$_GETdirectly.require_login($course)checks the session and course access before anything else runs;require_capability()then checks your new capability in the course context.format_text()andformat_string()escape and filter output. Output must never echo raw user or admin input.
Visit /local/helpdesk/index.php?id=COURSEID as a student to check it works, and as a user with no role in the course to check that it refuses access.
Step 5: add the page to course navigation
Local plugins can add nodes to course navigation with a lib.php callback. Moodle 5.3 still calls extend_navigation_course callbacks from plugins that define them:
<?php
defined('MOODLE_INTERNAL') || die();
/**
* Add a Help desk link to course navigation.
*
* @param navigation_node $parentnode
* @param stdClass $course
* @param context_course $context
*/
function local_helpdesk_extend_navigation_course(navigation_node $parentnode, stdClass $course, context_course $context): void {
if (!has_capability('local/helpdesk:view', $context)) {
return;
}
$url = new moodle_url('/local/helpdesk/index.php', ['id' => $course->id]);
$parentnode->add(
get_string('pluginname', 'local_helpdesk'),
$url,
navigation_node::TYPE_SETTING,
null,
'local_helpdesk',
new pix_icon('i/info', '')
);
}
The node is added to the course administration node, and Boost moves custom nodes from there into the course's secondary navigation, often under More. Moodle caches which plugins implement which callbacks, so purge caches or bump the version after adding this file. Always repeat the capability check here: hiding a link is a convenience, while require_capability() in index.php is the real protection.
Step 6: declare what the plugin stores
Every plugin should implement the Privacy API so data export and deletion requests cover it. This plugin stores no personal data, so a null provider is enough. Create classes/privacy/provider.php:
<?php
namespace local_helpdesk\privacy;
/**
* Privacy provider for local_helpdesk: no personal data stored.
*/
class provider implements \core_privacy\local\metadata\null_provider {
public static function get_reason(): string {
return 'privacy:metadata';
}
}
Classes under classes/ are autoloaded by namespace, so no require is needed. If you later add a database table that holds user data, replace this with a full provider.
Testing, uninstalling and where to go next
Before you call the plugin done:
- Run the upgrade on a clean copy of the site to prove the plugin installs from scratch.
- Check the page as an admin, a teacher, a student and a user without a role.
- Run the Moodle coding style checks and PHPUnit in CI; our guide to CI/CD for LMS customisations covers moodle-plugin-ci.
- Uninstall it to prove it cleans up: Site administration > Plugins > Plugins overview, or
php admin/cli/uninstall_plugins.php --plugins=local_helpdesk --run(without--runit is a dry run).
The same plugin can grow with the other standard files: db/install.xml for tables (built with the XMLDB editor), db/upgrade.php for schema changes, db/events.php for event observers, db/tasks.php for scheduled tasks, db/services.php for web service functions and db/hooks.php for Hooks API callbacks. When you upgrade Moodle later, the plugin manager compares the supported range with the new branch when it checks plugin requirements, so update it as you test each release; our article on a Moodle plugin not compatible after an upgrade explains what to do.
How we can help
We build, review and maintain custom Moodle plugins, from small local plugins like this one to integrations with HR, CRM and payment systems. See our Moodle development services, or start with the Moodle guide for the wider picture.
Frequently asked questions
Where do local plugins go in Moodle 5.1 and later?
In public/local/<pluginname>. From Moodle 5.1 web-accessible code lives in the public directory, while config.php and the admin/cli scripts remain in the project root. On Moodle 5.0 and earlier the folder is local/<pluginname>.
What files does a Moodle local plugin need?
At minimum version.php, with the component name and version number, and lang/en/local_<pluginname>.php with a pluginname string. Settings, capabilities, observers, tasks and web services are added with the standard plugin files such as settings.php and db/access.php.
Why does my local plugin settings.php throw an error?
Unlike most plugin types, Moodle does not create a settings page for local plugins, so $settings is null. Create an admin_settingpage yourself inside an if ($hassiteconfig) block and add it to the localplugins category with $ADMIN->add().
When should I increase the plugin version number?
Whenever Moodle needs to notice a change: new or changed capabilities, database schema, event observers, scheduled tasks, web services or settings structure. Increasing $plugin->version and running the upgrade applies them.
Free 30-minute consultation
Not sure where to start? Talk to an LMS engineer.
Tell us what platform you run and what you need. You’ll get honest, practical advice - even if the answer is that you don’t need us.
Sources & references
- Local plugins - Moodle
- version.php - Moodle
- Plugin files - Moodle
- Directory restructure in Moodle 5.1 - Moodle
- Moodle 5.3.0 source - Moodle
Learn more about this topic with AI
Gemini opens with the prompt copied - paste it into the chat to start.