Create custom external web service APIs for Moodle LMS. Use when implementing web services for course management, user tracking, quiz operations, or custom plugin functionality. Covers parameter validation, database operations, error handling, service registration, and Moodle coding standards.
Add this skill
npx mdskills install sickn33/moodle-external-api-developmentComprehensive Moodle API development guide with excellent security practices and detailed examples
This skill guides you through creating custom external web service APIs for Moodle LMS, following Moodle's external API framework and coding standards.
Moodle external APIs follow a strict three-method pattern:
execute_parameters() - Defines input parameter structureexecute() - Contains business logicexecute_returns() - Defines return structureLocation: /local/yourplugin/classes/external/your_api_name.php
libdir/externallib.php");
use external_api;
use external_function_parameters;
use external_single_structure;
use external_value;
class your_api_name extends external_api {
// Three required methods will go here
}
Key Points:
external_apilocal_pluginname\external or mod_modname\externaldefined('MOODLE_INTERNAL') || die();public static function execute_parameters() {
return new external_function_parameters([
'userid' => new external_value(PARAM_INT, 'User ID', VALUE_REQUIRED),
'courseid' => new external_value(PARAM_INT, 'Course ID', VALUE_REQUIRED),
'options' => new external_single_structure([
'includedetails' => new external_value(PARAM_BOOL, 'Include details', VALUE_DEFAULT, false),
'limit' => new external_value(PARAM_INT, 'Result limit', VALUE_DEFAULT, 10)
], 'Options', VALUE_OPTIONAL)
]);
}
Common Parameter Types:
PARAM_INT - IntegersPARAM_TEXT - Plain text (HTML stripped)PARAM_RAW - Raw text (no cleaning)PARAM_BOOL - Boolean valuesPARAM_FLOAT - Floating point numbersPARAM_ALPHANUMEXT - Alphanumeric with extended charsStructures:
external_value - Single valueexternal_single_structure - Object with named fieldsexternal_multiple_structure - Array of itemsValue Flags:
VALUE_REQUIRED - Parameter must be providedVALUE_OPTIONAL - Parameter is optionalVALUE_DEFAULT, defaultvalue - Optional with defaultpublic static function execute($userid, $courseid, $options = []) {
global $DB, $USER;
// 1. Validate parameters
$params = self::validate_parameters(self::execute_parameters(), [
'userid' => $userid,
'courseid' => $courseid,
'options' => $options
]);
// 2. Check permissions/capabilities
$context = \context_course::instance($params['courseid']);
self::validate_context($context);
require_capability('moodle/course:view', $context);
// 3. Verify user access
if ($params['userid'] != $USER->id) {
require_capability('moodle/course:viewhiddenactivities', $context);
}
// 4. Database operations
$sql = "SELECT id, name, timecreated
FROM {your_table}
WHERE userid = :userid
AND courseid = :courseid
LIMIT :limit";
$records = $DB->get_records_sql($sql, [
'userid' => $params['userid'],
'courseid' => $params['courseid'],
'limit' => $params['options']['limit']
]);
// 5. Process and return data
$results = [];
foreach ($records as $record) {
$results[] = [
'id' => $record->id,
'name' => $record->name,
'timestamp' => $record->timecreated
];
}
return [
'items' => $results,
'count' => count($results)
];
}
Critical Steps:
validate_parameters()validate_context()require_capability()public static function execute_returns() {
return new external_single_structure([
'items' => new external_multiple_structure(
new external_single_structure([
'id' => new external_value(PARAM_INT, 'Item ID'),
'name' => new external_value(PARAM_TEXT, 'Item name'),
'timestamp' => new external_value(PARAM_INT, 'Creation time')
])
),
'count' => new external_value(PARAM_INT, 'Total items')
]);
}
Return Structure Rules:
execute() returnsLocation: /local/yourplugin/db/services.php
[
'classname' => 'local_yourplugin\external\your_api_name',
'methodname' => 'execute',
'classpath' => 'local/yourplugin/classes/external/your_api_name.php',
'description' => 'Brief description of what this API does',
'type' => 'read', // or 'write'
'ajax' => true,
'capabilities'=> 'moodle/course:view', // comma-separated if multiple
'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE] // Optional
],
];
$services = [
'Your Plugin Web Service' => [
'functions' => [
'local_yourplugin_your_api_name'
],
'restrictedusers' => 0,
'enabled' => 1
]
];
Service Registration Keys:
classname - Full namespaced class namemethodname - Always 'execute'type - 'read' (SELECT) or 'write' (INSERT/UPDATE/DELETE)ajax - Set true for AJAX/REST accesscapabilities - Required Moodle capabilitiesservices - Optional service bundlesprivate static function log_debug($message) {
global $CFG;
$logdir = $CFG->dataroot . '/local_yourplugin';
if (!file_exists($logdir)) {
mkdir($logdir, 0777, true);
}
$debuglog = $logdir . '/api_debug.log';
$timestamp = date('Y-m-d H:i:s');
file_put_contents($debuglog, "[$timestamp] $message\n", FILE_APPEND | LOCK_EX);
}
public static function execute($userid, $courseid) {
global $DB;
try {
self::log_debug("API called: userid=$userid, courseid=$courseid");
// Validate parameters
$params = self::validate_parameters(self::execute_parameters(), [
'userid' => $userid,
'courseid' => $courseid
]);
// Your logic here
self::log_debug("API completed successfully");
return $result;
} catch (\invalid_parameter_exception $e) {
self::log_debug("Parameter validation failed: " . $e->getMessage());
throw $e;
} catch (\moodle_exception $e) {
self::log_debug("Moodle exception: " . $e->getMessage());
throw $e;
} catch (\Exception $e) {
// Log detailed error info
$lastsql = method_exists($DB, 'get_last_sql') ? $DB->get_last_sql() : '[N/A]';
self::log_debug("Fatal error: " . $e->getMessage());
self::log_debug("Last SQL: " . $lastsql);
self::log_debug("Stack trace: " . $e->getTraceAsString());
throw $e;
}
}
Error Handling Best Practices:
// Transaction example
$transaction = $DB->start_delegated_transaction();
try {
// Insert record
$recordid = $DB->insert_record('your_table', $dataobject);
// Update related records
$DB->set_field('another_table', 'status', 1, ['recordid' => $recordid]);
// Commit transaction
$transaction->allow_commit();
} catch (\Exception $e) {
$transaction->rollback($e);
throw $e;
}
// Create course module
$moduleid = $DB->get_field('modules', 'id', ['name' => 'quiz'], MUST_EXIST);
$cm = new \stdClass();
$cm->course = $courseid;
$cm->module = $moduleid;
$cm->instance = 0; // Will be updated after activity creation
$cm->visible = 1;
$cm->groupmode = 0;
$cmid = add_course_module($cm);
// Create activity instance (e.g., quiz)
$quiz = new \stdClass();
$quiz->course = $courseid;
$quiz->name = 'My Quiz';
$quiz->coursemodule = $cmid;
// ... other quiz fields ...
$quizid = quiz_add_instance($quiz, null);
// Update course module with instance ID
$DB->set_field('course_modules', 'instance', $quizid, ['id' => $cmid]);
course_add_cm_to_section($courseid, $cmid, 0);
// Restrict activity to specific user via group
$groupname = 'activity_' . $activityid . '_user_' . $userid;
// Create or get group
if (!$groupid = $DB->get_field('groups', 'id', ['courseid' => $courseid, 'name' => $groupname])) {
$groupdata = (object)[
'courseid' => $courseid,
'name' => $groupname,
'timecreated' => time(),
'timemodified' => time()
];
$groupid = $DB->insert_record('groups', $groupdata);
}
// Add user to group
if (!$DB->record_exists('groups_members', ['groupid' => $groupid, 'userid' => $userid])) {
$DB->insert_record('groups_members', (object)[
'groupid' => $groupid,
'userid' => $userid,
'timeadded' => time()
]);
}
// Set availability condition
$restriction = [
'op' => '&',
'show' => false,
'c' => [
[
'type' => 'group',
'id' => $groupid
]
],
'showc' => [false]
];
$DB->set_field('course_modules', 'availability', json_encode($restriction), ['id' => $cmid]);
private static function get_random_questions($categoryid, $tagname, $limit) {
global $DB;
$sql = "SELECT q.id
FROM {question} q
INNER JOIN {question_versions} qv ON qv.questionid = q.id
INNER JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
INNER JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid
JOIN {tag_instance} ti ON ti.itemid = q.id
JOIN {tag} t ON t.id = ti.tagid
WHERE LOWER(t.name) = :tagname
AND qc.id = :categoryid
AND ti.itemtype = 'question'
AND q.qtype = 'multichoice'";
$qids = $DB->get_fieldset_sql($sql, [
'categoryid' => $categoryid,
'tagname' => strtolower($tagname)
]);
shuffle($qids);
return array_slice($qids, 0, $limit);
}
# Get token first
curl -X POST "https://yourmoodle.com/login/token.php" \
-d "username=admin" \
-d "password=yourpassword" \
-d "service=moodle_mobile_app"
# Call your API
curl -X POST "https://yourmoodle.com/webservice/rest/server.php" \
-d "wstoken=YOUR_TOKEN" \
-d "wsfunction=local_yourplugin_your_api_name" \
-d "moodlewsrestformat=json" \
-d "userid=2" \
-d "courseid=3"
require(['core/ajax'], function(ajax) {
var promises = ajax.call([{
methodname: 'local_yourplugin_your_api_name',
args: {
userid: 2,
courseid: 3
}
}]);
promises[0].done(function(response) {
console.log('Success:', response);
}).fail(function(error) {
console.error('Error:', error);
});
});
Solution:
Solution:
Solution:
:paramname)get_record(), get_records(), etc.Solution:
self::validate_context($context) early in execute()Solution:
$CFG->dataroot/local_yourplugin/$DB->set_debug(true)local/yourplugin/
├── version.php # Plugin version and metadata
├── db/
│ ├── services.php # External service definitions
│ └── access.php # Capability definitions (optional)
├── classes/
│ └── external/
│ ├── your_api_name.php # External API implementation
│ └── another_api.php # Additional APIs
├── lang/
│ └── en/
│ └── local_yourplugin.php # Language strings
└── tests/
└── external_test.php # Unit tests (optional but recommended)
libdir/externallib.php");
use external_api;
use external_function_parameters;
use external_single_structure;
use external_value;
class get_quiz_attempts extends external_api {
public static function execute_parameters() {
return new external_function_parameters([
'userid' => new external_value(PARAM_INT, 'User ID'),
'courseid' => new external_value(PARAM_INT, 'Course ID')
]);
}
public static function execute($userid, $courseid) {
global $DB;
self::validate_parameters(self::execute_parameters(), [
'userid' => $userid,
'courseid' => $courseid
]);
$sql = "SELECT COUNT(*) AS quiz_attempts
FROM {quiz_attempts} qa
JOIN {quiz} q ON qa.quiz = q.id
WHERE qa.userid = :userid AND q.course = :courseid";
$attempts = $DB->get_field_sql($sql, [
'userid' => $userid,
'courseid' => $courseid
]);
return ['quiz_attempts' => (int)$attempts];
}
public static function execute_returns() {
return new external_single_structure([
'quiz_attempts' => new external_value(PARAM_INT, 'Total number of quiz attempts')
]);
}
}
See attached create_quiz_from_categories.php for a comprehensive example including:
| Table | Purpose |
|---|---|
{user} | User accounts |
{course} | Courses |
{course_modules} | Activity instances in courses |
{modules} | Available activity types (quiz, forum, etc.) |
{quiz} | Quiz configurations |
{quiz_attempts} | Quiz attempt records |
{question} | Question bank |
{question_categories} | Question categories |
{grade_items} | Gradebook items |
{grade_grades} | Student grades |
{groups} | Course groups |
{groups_members} | Group memberships |
{logstore_standard_log} | Activity logs |
validate_parameters()Install via CLI
npx mdskills install sickn33/moodle-external-api-developmentMoodle External API Development is a free, open-source AI agent skill. Create custom external web service APIs for Moodle LMS. Use when implementing web services for course management, user tracking, quiz operations, or custom plugin functionality. Covers parameter validation, database operations, error handling, service registration, and Moodle coding standards.
Install Moodle External API Development with a single command:
npx mdskills install sickn33/moodle-external-api-developmentThis downloads the skill files into your project and your AI agent picks them up automatically.
Moodle External API Development works with Claude Code, Claude Desktop, Cursor, Vscode Copilot, Windsurf, Continue Dev, Codex, Gemini Cli, Amp, Roo Code, Goose, Opencode, Trae, Qodo, Command Code. Skills use the open SKILL.md format which is compatible with any AI coding agent that reads markdown instructions.