Help us improve
Share bugs, ideas, or general feedback.
From moodle-dev
Adds and calls Moodle web services: defines functions in db/services.php and classes/external/, handles REST/SOAP/AJAX protocols, tokens, capabilities, parameter/return schemas, file uploads, and rate limiting.
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-web-servicesThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Moodle exposes plugin functions as web services via `db/services.php` + `classes/external/<fn>.php`. Same function is callable as REST, AJAX, SOAP, or XMLRPC. Authentication is token-based for external clients, session-based for AJAX. All inputs/outputs are typed via `external_value` / `external_single_structure` / `external_multiple_structure`.
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.
Guides Moodle plugin development: version.php, DB install/upgrade, capabilities, web services, PSR-4 autoloading, hooks, settings, privacy provider, and coding standards.
Guides MCP server integration into Claude Code plugins via .mcp.json or plugin.json, covering stdio, SSE, HTTP for external tools like filesystems and APIs.
Share bugs, ideas, or general feedback.
Moodle exposes plugin functions as web services via db/services.php + classes/external/<fn>.php. Same function is callable as REST, AJAX, SOAP, or XMLRPC. Authentication is token-based for external clients, session-based for AJAX. All inputs/outputs are typed via external_value / external_single_structure / external_multiple_structure.
Skip when: building UI-only PHP (no remote callers) — use moodle-plugin-development.
<plugin>/
db/services.php # function + service definitions
classes/external/get_items.php # one file per external function
classes/external/get_items_returns.php # (optional) split returns
lang/en/<component>.php # service descriptions
<?php
defined('MOODLE_INTERNAL') || die();
$functions = [
'local_example_get_items' => [
'classname' => 'local_example\external\get_items',
'methodname' => 'execute', // default: 'execute'
'description' => 'Return items in a course',
'type' => 'read', // 'read' or 'write'
'ajax' => true, // callable from core/ajax
'capabilities' => 'local/example:view', // checked in addition to per-method checks
'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE], // expose to mobile
],
'local_example_mark_present' => [
'classname' => 'local_example\external\mark_present',
'description' => 'Mark a user present',
'type' => 'write',
'ajax' => true,
'capabilities' => 'local/example:mark',
],
];
$services = [
'Example REST API' => [
'functions' => array_keys($functions),
'restrictedusers' => 1, // require explicit user assignment
'enabled' => 1,
'shortname' => 'local_example_api',
'downloadfiles' => 1,
'uploadfiles' => 0,
],
];
After editing: bump version.php, run php admin/cli/upgrade.php.
<?php
namespace local_example\external;
defined('MOODLE_INTERNAL') || die();
use core_external\external_api;
use core_external\external_function_parameters;
use core_external\external_value;
use core_external\external_single_structure;
use core_external\external_multiple_structure;
class get_items extends external_api {
public static function execute_parameters(): external_function_parameters {
return new external_function_parameters([
'courseid' => new external_value(PARAM_INT, 'Course ID'),
'limit' => new external_value(PARAM_INT, 'Max items', VALUE_DEFAULT, 50),
]);
}
public static function execute(int $courseid, int $limit = 50): array {
global $DB, $USER;
// 1. Validate params (re-runs schema, normalizes types)
$params = self::validate_parameters(self::execute_parameters(), [
'courseid' => $courseid,
'limit' => $limit,
]);
// 2. Validate context + capability
$context = \context_course::instance($params['courseid']);
self::validate_context($context);
require_capability('local/example:view', $context);
// 3. Business logic
$rows = $DB->get_records('local_example_items',
['courseid' => $params['courseid']], 'timecreated DESC', '*', 0, $params['limit']);
return array_values(array_map(fn($r) => [
'id' => (int)$r->id,
'name' => format_string($r->name, true, ['context' => $context]),
'time' => (int)$r->timecreated,
], $rows));
}
public static function execute_returns(): external_multiple_structure {
return new external_multiple_structure(
new external_single_structure([
'id' => new external_value(PARAM_INT, 'ID'),
'name' => new external_value(PARAM_TEXT, 'Name'),
'time' => new external_value(PARAM_INT, 'Created timestamp'),
])
);
}
}
Required ordering inside execute():
validate_parameters()validate_context()require_capability()format_string, format_text with context)Skipping any of (1)-(3) is a security bug.
| Moodle | Namespace |
|---|---|
| 4.2+ | core_external\external_api (etc.) |
| ≤ 4.1 | bare external_api (no namespace) |
For 4.2+ compatibility wrappers, see lib/classes/external/.
| Constant | Use |
|---|---|
PARAM_INT | Integers |
PARAM_FLOAT | Decimals |
PARAM_BOOL | true/false |
PARAM_TEXT | Plain text (strips tags) |
PARAM_RAW | Untrusted text — use only if you re-format on output |
PARAM_NOTAGS | Strips tags, keeps entities |
PARAM_CLEANHTML | Sanitized HTML |
PARAM_ALPHANUM | a-zA-Z0-9 |
PARAM_ALPHA | a-zA-Z |
PARAM_USERNAME | Validates Moodle username |
PARAM_EMAIL | Validates email |
PARAM_URL | Validates URL |
PARAM_FILE | Filename (no path) |
PARAM_PATH | Path (no ..) |
PARAM_COMPONENT | frankenstyle (local_example) |
PARAM_CAPABILITY | local/example:view |
PARAM_PLUGIN | <name> part |
PARAM_AREA | File area names |
PARAM_BASE64 | Base64 |
VALUE_REQUIRED (default), VALUE_OPTIONAL (omit from response if null), VALUE_DEFAULT (with default).
TOKEN=abcdef0123456789
SITE=https://moodle.example.com
curl -X POST "$SITE/webservice/rest/server.php" \
-d "wstoken=$TOKEN" \
-d "moodlewsrestformat=json" \
-d "wsfunction=local_example_get_items" \
-d "courseid=5" \
-d "limit=10"
Response on error:
{"exception":"invalid_parameter_exception","errorcode":"invalidparameter","message":"..."}
import Ajax from 'core/ajax';
const [items] = await Ajax.call([{
methodname: 'local_example_get_items',
args: {courseid: 5, limit: 10},
}]);
Requires 'ajax' => true in db/services.php. Session cookie auths; no token needed.
# Site admin > Server > Web services > Manage tokens
# or programmatically:
$service = $DB->get_record('external_services', ['shortname' => 'local_example_api']);
$token = \core_external\util::generate_token(
EXTERNAL_TOKEN_PERMANENT,
$service,
$userid,
\context_system::instance()
);
POST /webservice/upload.php?token=$T&filearea=draft&itemid=0itemiditemid to your function via a PARAM_INT paramexecute(), move from draft to plugin area:file_save_draft_area_files($params['draftitemid'], $context->id,
'local_example', 'attachments', $itemid,
['subdirs' => 0, 'maxfiles' => 5]);
Service must have uploadfiles => 1.
Set downloadfiles => 1 on service, serve via pluginfile.php. Token-authed clients append ?token=$T to pluginfile URLs.
No built-in per-token rate limit. Options:
\core\session\manager::is_loggedinas() checkslib/setuplib.php hooklimit_req)| Mistake | Fix |
|---|---|
Skipping validate_parameters() | Always call first — ensures types match schema |
Skipping validate_context() | Required for capability + permission scoping |
| Returning unformatted user text | Run through format_string() / format_text() with context |
| Schema mismatch in returns | Wrap response with clean_returnvalue in tests |
| Wrong namespace pre-4.2 | Use external_api bare; for 4.2+ use core_external\external_api |
'ajax' => true missing for browser calls | Calls fail with accessexception |
Not bumping version.php after db/services.php change | Service won't register — bump + upgrade |
| Token user lacks required capability | require_capability fails — assign user to service or grant cap |
restrictedusers => 1 but no users assigned | Site admin > Server > Web services > Authorised users |
MOODLE_OFFICIAL_MOBILE_SERVICE exposes more than intended | Audit — only add functions safe for mobile app |
// tests/external/get_items_test.php
$this->setUser($user);
$result = get_items::execute($course->id, 10);
$result = external_api::clean_returnvalue(get_items::execute_returns(), $result);
$this->assertCount(2, $result);
clean_returnvalue is mandatory — catches return schema bugs.