Moodle Plugin Architecture

Moodle Local Plugin Development, Explained Properly

A local plugin is Moodle's general-purpose extension point — the right home for admin tools, integration APIs, and site-wide logic that doesn't belong in a block, report, or activity module. Here's how local_pluginname actually works, file by file, API by API.

8+ Years Building on MoodleMoodle 4.x & 5.xPlugin-Directory-Ready Code
local_pluginname Directory Structure

Most Moodle Sites Outgrow the Plugin Directory Eventually

Blocks, reports, and third-party plugins cover the common cases. Sooner or later a Moodle site needs something the plugin directory doesn't have — a custom API another system calls, a scheduled sync job, or an admin tool specific to one institution's workflow. That's what a local plugin is for, and getting its structure wrong is one of the most common ways a "quick script" turns into an unmaintainable mess.

Wrong Plugin Type Chosen

Logic forced into a block or report plugin because it was the first type the developer understood, instead of the type that actually fits.

No Capability System

Access control hard-coded against specific user IDs or role shortnames instead of Moodle's own capability and context system.

Raw SQL Against the Database

Direct queries that work on the current database engine and break — or open a SQL injection hole — on any other.

Local Plugin vs. Block, Report, Tool, and Activity Module

Moodle defines several distinct plugin types, and each one exists for a reason. Picking the wrong one doesn't just look odd in the code — it means fighting Moodle's own architecture at every step.

A local plugin (frankenstyle prefix local_, living in /local/pluginname) is Moodle's catch-all for functionality that isn't tied to a course, a specific block region, or a report screen — integration APIs, scheduled tasks, cross-cutting business logic, or a settings page under Site administration. A block is a small widget rendered in a block region on a dashboard or course page — nothing more. A report plugin specifically renders under Site administration > Reports or a course's own Reports tab. An admin tool (admin/tool) is conventionally reserved for site-management utilities shipped close to core, while a local plugin is the more general option for third-party and custom work. An activity module (mod_*) is the heaviest of the five — it needs course-module instances, gradebook hooks, and backup/restore support, which is overkill for something that isn't actually a learning activity.

In practice, most custom integration work — SSO connectors, external system sync jobs, custom reporting APIs consumed by other plugins, one-off admin utilities — belongs in a local plugin. If you're unsure which type fits, the fastest test is: does this need a course-module instance with a grade? If not, it's very likely local, block, or report — not an activity module.

The Files Every Local Plugin Actually Needs

Moodle expects a specific directory structure. Miss a file and the plugin either won't install or won't behave predictably on upgrade.

version.php

Declares $plugin->component (local_pluginname), $plugin->version, $plugin->requires (minimum Moodle core version), and $plugin->maturity. Required in every plugin, checked on every upgrade run.

db/access.php

Defines every custom capability your plugin introduces — its captype, context level, risk bitmask, and default archetype permissions for each role.

db/install.xml

The initial database schema in Moodle's XMLDB format, normally generated with the built-in XMLDB editor rather than written by hand.

db/upgrade.php

Schema and data migration steps gated behind version checks, each closed off with upgrade_plugin_savepoint() so a failed upgrade can resume safely.

classes/

PSR-4 autoloaded classes namespaced local_pluginname\, covering everything from external API classes to scheduled task definitions and privacy providers.

lang/en/local_pluginname.php

Every user-facing string as a $string['identifier'] entry — Moodle plugins can't hard-code English text directly in templates or PHP output.

Capabilities and Context Levels, Done Correctly

Moodle's permission model is capability-based, not role-based — roles are just named bundles of capabilities assigned at a context.

Every custom capability a local plugin introduces gets declared in db/access.php as an entry with a captype (read or write), a contextlevel — most commonly CONTEXT_SYSTEM for a site-wide tool, but potentially CONTEXT_COURSECAT, CONTEXT_COURSE, CONTEXT_MODULE, or CONTEXT_USER depending on scope — a riskbitmask flagging risks like data loss or privacy exposure, and default archetype permissions (for example, granting CAP_ALLOW to the manager archetype by default). Code then checks that capability with require_capability('local_pluginname:managesync', $context) to hard-stop unauthorized access, or has_capability() where a soft check is enough — for example, deciding whether to show a menu item at all. Getting the context level right matters: a capability defined at the wrong level either can't be assigned where it's needed, or ends up applying far more broadly than intended.

Install, Upgrade, and Uninstall Hooks

A plugin isn't just installed once and forgotten — Moodle expects every version bump to be handled explicitly.

1

Install

Moodle reads version.php, runs db/install.xml to create tables, then executes db/install.php for any one-time setup install.xml can't express, such as inserting default records.

2

Upgrade

On every core cron/admin visit, Moodle compares the installed version against the version.php in code and runs db/upgrade.php step by step until they match.

3

Uninstall

When a plugin is removed through Site administration, db/uninstall.php runs first for custom cleanup, then Moodle drops the plugin's own database tables automatically.

The $plugin->requires value in version.php also matters more than it looks: it declares the minimum Moodle core version the plugin needs, and Moodle will refuse to install or enable a plugin against an older core rather than fail unpredictably at runtime.

Why the DB API Replaces Raw SQL

Moodle runs on MySQL, MariaDB, PostgreSQL, MSSQL, and Oracle behind one codebase — and the DB API is what makes that possible.

  • $DB->get_record() / get_records() / get_records_sql() for reads, with parameters passed separately rather than concatenated into the query string.
  • $DB->insert_record(), update_record(), and delete_records() for writes, all schema-aware and transaction-safe.
  • Table structures declared once as XMLDB definitions in db/install.xml, so field types map correctly across every supported database engine.
  • Parameterized queries as the default, closing off the SQL injection vector that raw string-built queries leave wide open.

A plugin that queries $DB directly instead of building raw SQL strings is also one that keeps working the day a client migrates from MySQL to PostgreSQL — something that happens more often on long-lived institutional Moodle installs than most developers expect.

Coding Standards, PHPUnit, and Behat

The bar for a plugin that's actually maintainable is the same bar the Moodle Plugins directory review enforces.

Every file should pass the moodle PHP_CodeSniffer standard (moodle-cs) — spacing, PHPDoc blocks, naming conventions, and a long list of Moodle-specific rules like avoiding direct superglobal access. Business logic belongs in PHPUnit tests under tests/, extending advanced_testcase so each test runs against a clean, isolated database reset. Anything with a user interface needs Behat feature tests under tests/behat/, which drive a real browser session against Moodle's own step definitions. For public submission, that combination — clean coding standards, a GPLv3+ license header, a privacy provider implementing the correct interface for GDPR compliance, and no leftover debugging output — is exactly what the plugins directory review checks line by line. Building to that bar from day one, even for a private plugin, is what keeps a local plugin upgradeable years later instead of becoming the one file nobody wants to touch.

Local Plugins Built to Moodle's Own Standards

Built Against Real Moodle APIs

Every local plugin uses the documented plugin API, DB API, and capability system — never raw SQL or undocumented core internals that break on the next release.

8+ Years of Moodle-Specific Experience

We've built, reviewed, and debugged local plugins across dozens of Moodle installs, so we know which shortcuts cause problems six months later.

Plugin-Directory-Ready Code

Coding-standards compliant, tested, and documented from the first commit — whether the plugin stays private or ships to the public Moodle Plugins directory.

You Own the Code

Your local plugin's full source lives on your Moodle installation. Nothing rented, nothing locked behind an ongoing license with us.

Got Questions About Moodle Local Plugins?

Straight answers to what comes up most when planning a local plugin build.

A local plugin (component name local_pluginname, living in /local/pluginname) is Moodle's general-purpose plugin type for functionality that doesn't fit a course, a block, or a report — things like custom admin tools, integration APIs, scheduled background jobs, or site-wide settings pages. It's the closest thing Moodle has to "build whatever you need here."

Use a block when you need a small widget in a block region, a report plugin when the output specifically belongs under Site administration > Reports or a course's report tab, and an activity module (mod_*) when it needs a gradable, course-embedded instance. A local plugin is the right call for everything else — cross-cutting logic, admin utilities, external APIs, or functionality other plugins depend on.

Frankenstyle is Moodle's plugin naming convention: <type>_<pluginname>, so a local plugin called "attendance sync" becomes local_attendancesync. Moodle uses this exact string as the component name in the database, capability names, language string files, and class namespaces — get it wrong and the plugin simply won't be recognized.

Yes, if the plugin restricts access to anything beyond a logged-in user. Capabilities defined in db/access.php, checked with require_capability() or has_capability(), are what let you say "only managers at the system context can run this" instead of hard-coding role checks that ignore Moodle's actual permission system.

Moodle supports MySQL, MariaDB, PostgreSQL, MSSQL, and Oracle behind the same codebase. The DB API ($DB->get_records, $DB->insert_record, and friends) abstracts those differences and uses parameterized queries by default, which is also your main defense against SQL injection. Raw SQL is a fast way to fail a plugin review and to break on any database other than the one you tested on.

For anything submitted to the Moodle Plugins directory, yes — the review process expects PHPUnit tests for logic classes and Behat feature tests for anything with a UI, plus a clean run of the moodle-cs coding standard. For internal, private plugins we still write them, because it's the difference between a plugin you can safely upgrade in three years and one nobody wants to touch.

Yes — that's exactly what db/install.xml is for. Tables are defined once at install time and evolved through versioned steps in db/upgrade.php as the plugin changes, the same schema-migration pattern Moodle core itself uses for every subsystem.

Need a Local Plugin Built Right the First Time?

Tell us what your Moodle site needs to do and we'll scope a local plugin built against Moodle's actual APIs — not a workaround.

Talk to a Moodle Developer