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
1---2name: moodle-external-api-development3description: 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.4---56# Moodle External API Development78This skill guides you through creating custom external web service APIs for Moodle LMS, following Moodle's external API framework and coding standards.910## When to Use This Skill1112- Creating custom web services for Moodle plugins13- Implementing REST/AJAX endpoints for course management14- Building APIs for quiz operations, user tracking, or reporting15- Exposing Moodle functionality to external applications16- Developing mobile app backends using Moodle1718## Core Architecture Pattern1920Moodle external APIs follow a strict three-method pattern:21221. **`execute_parameters()`** - Defines input parameter structure232. **`execute()`** - Contains business logic243. **`execute_returns()`** - Defines return structure2526## Step-by-Step Implementation2728### Step 1: Create the External API Class File2930**Location**: `/local/yourplugin/classes/external/your_api_name.php`3132```php33<?php34namespace local_yourplugin\external;3536defined('MOODLE_INTERNAL') || die();37require_once("$CFG->libdir/externallib.php");3839use external_api;40use external_function_parameters;41use external_single_structure;42use external_value;4344class your_api_name extends external_api {4546 // Three required methods will go here4748}49```5051**Key Points**:52- Class must extend `external_api`53- Namespace follows: `local_pluginname\external` or `mod_modname\external`54- Include the security check: `defined('MOODLE_INTERNAL') || die();`55- Require externallib.php for base classes5657### Step 2: Define Input Parameters5859```php60public static function execute_parameters() {61 return new external_function_parameters([62 'userid' => new external_value(PARAM_INT, 'User ID', VALUE_REQUIRED),63 'courseid' => new external_value(PARAM_INT, 'Course ID', VALUE_REQUIRED),64 'options' => new external_single_structure([65 'includedetails' => new external_value(PARAM_BOOL, 'Include details', VALUE_DEFAULT, false),66 'limit' => new external_value(PARAM_INT, 'Result limit', VALUE_DEFAULT, 10)67 ], 'Options', VALUE_OPTIONAL)68 ]);69}70```7172**Common Parameter Types**:73- `PARAM_INT` - Integers74- `PARAM_TEXT` - Plain text (HTML stripped)75- `PARAM_RAW` - Raw text (no cleaning)76- `PARAM_BOOL` - Boolean values77- `PARAM_FLOAT` - Floating point numbers78- `PARAM_ALPHANUMEXT` - Alphanumeric with extended chars7980**Structures**:81- `external_value` - Single value82- `external_single_structure` - Object with named fields83- `external_multiple_structure` - Array of items8485**Value Flags**:86- `VALUE_REQUIRED` - Parameter must be provided87- `VALUE_OPTIONAL` - Parameter is optional88- `VALUE_DEFAULT, defaultvalue` - Optional with default8990### Step 3: Implement Business Logic9192```php93public static function execute($userid, $courseid, $options = []) {94 global $DB, $USER;9596 // 1. Validate parameters97 $params = self::validate_parameters(self::execute_parameters(), [98 'userid' => $userid,99 'courseid' => $courseid,100 'options' => $options101 ]);102103 // 2. Check permissions/capabilities104 $context = \context_course::instance($params['courseid']);105 self::validate_context($context);106 require_capability('moodle/course:view', $context);107108 // 3. Verify user access109 if ($params['userid'] != $USER->id) {110 require_capability('moodle/course:viewhiddenactivities', $context);111 }112113 // 4. Database operations114 $sql = "SELECT id, name, timecreated115 FROM {your_table}116 WHERE userid = :userid117 AND courseid = :courseid118 LIMIT :limit";119120 $records = $DB->get_records_sql($sql, [121 'userid' => $params['userid'],122 'courseid' => $params['courseid'],123 'limit' => $params['options']['limit']124 ]);125126 // 5. Process and return data127 $results = [];128 foreach ($records as $record) {129 $results[] = [130 'id' => $record->id,131 'name' => $record->name,132 'timestamp' => $record->timecreated133 ];134 }135136 return [137 'items' => $results,138 'count' => count($results)139 ];140}141```142143**Critical Steps**:1441. **Always validate parameters** using `validate_parameters()`1452. **Check context** using `validate_context()`1463. **Verify capabilities** using `require_capability()`1474. **Use parameterized queries** to prevent SQL injection1485. **Return structured data** matching return definition149150### Step 4: Define Return Structure151152```php153public static function execute_returns() {154 return new external_single_structure([155 'items' => new external_multiple_structure(156 new external_single_structure([157 'id' => new external_value(PARAM_INT, 'Item ID'),158 'name' => new external_value(PARAM_TEXT, 'Item name'),159 'timestamp' => new external_value(PARAM_INT, 'Creation time')160 ])161 ),162 'count' => new external_value(PARAM_INT, 'Total items')163 ]);164}165```166167**Return Structure Rules**:168- Must match exactly what `execute()` returns169- Use appropriate parameter types170- Document each field with description171- Nested structures allowed172173### Step 5: Register the Service174175**Location**: `/local/yourplugin/db/services.php`176177```php178<?php179defined('MOODLE_INTERNAL') || die();180181$functions = [182 'local_yourplugin_your_api_name' => [183 'classname' => 'local_yourplugin\external\your_api_name',184 'methodname' => 'execute',185 'classpath' => 'local/yourplugin/classes/external/your_api_name.php',186 'description' => 'Brief description of what this API does',187 'type' => 'read', // or 'write'188 'ajax' => true,189 'capabilities'=> 'moodle/course:view', // comma-separated if multiple190 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE] // Optional191 ],192];193194$services = [195 'Your Plugin Web Service' => [196 'functions' => [197 'local_yourplugin_your_api_name'198 ],199 'restrictedusers' => 0,200 'enabled' => 1201 ]202];203```204205**Service Registration Keys**:206- `classname` - Full namespaced class name207- `methodname` - Always 'execute'208- `type` - 'read' (SELECT) or 'write' (INSERT/UPDATE/DELETE)209- `ajax` - Set true for AJAX/REST access210- `capabilities` - Required Moodle capabilities211- `services` - Optional service bundles212213### Step 6: Implement Error Handling & Logging214215```php216private static function log_debug($message) {217 global $CFG;218 $logdir = $CFG->dataroot . '/local_yourplugin';219 if (!file_exists($logdir)) {220 mkdir($logdir, 0777, true);221 }222 $debuglog = $logdir . '/api_debug.log';223 $timestamp = date('Y-m-d H:i:s');224 file_put_contents($debuglog, "[$timestamp] $message\n", FILE_APPEND | LOCK_EX);225}226227public static function execute($userid, $courseid) {228 global $DB;229230 try {231 self::log_debug("API called: userid=$userid, courseid=$courseid");232233 // Validate parameters234 $params = self::validate_parameters(self::execute_parameters(), [235 'userid' => $userid,236 'courseid' => $courseid237 ]);238239 // Your logic here240241 self::log_debug("API completed successfully");242 return $result;243244 } catch (\invalid_parameter_exception $e) {245 self::log_debug("Parameter validation failed: " . $e->getMessage());246 throw $e;247 } catch (\moodle_exception $e) {248 self::log_debug("Moodle exception: " . $e->getMessage());249 throw $e;250 } catch (\Exception $e) {251 // Log detailed error info252 $lastsql = method_exists($DB, 'get_last_sql') ? $DB->get_last_sql() : '[N/A]';253 self::log_debug("Fatal error: " . $e->getMessage());254 self::log_debug("Last SQL: " . $lastsql);255 self::log_debug("Stack trace: " . $e->getTraceAsString());256 throw $e;257 }258}259```260261**Error Handling Best Practices**:262- Wrap logic in try-catch blocks263- Log errors with timestamps and context264- Capture SQL queries on database errors265- Preserve stack traces for debugging266- Re-throw exceptions after logging267268## Advanced Patterns269270### Complex Database Operations271272```php273// Transaction example274$transaction = $DB->start_delegated_transaction();275276try {277 // Insert record278 $recordid = $DB->insert_record('your_table', $dataobject);279280 // Update related records281 $DB->set_field('another_table', 'status', 1, ['recordid' => $recordid]);282283 // Commit transaction284 $transaction->allow_commit();285} catch (\Exception $e) {286 $transaction->rollback($e);287 throw $e;288}289```290291### Working with Course Modules292293```php294// Create course module295$moduleid = $DB->get_field('modules', 'id', ['name' => 'quiz'], MUST_EXIST);296297$cm = new \stdClass();298$cm->course = $courseid;299$cm->module = $moduleid;300$cm->instance = 0; // Will be updated after activity creation301$cm->visible = 1;302$cm->groupmode = 0;303$cmid = add_course_module($cm);304305// Create activity instance (e.g., quiz)306$quiz = new \stdClass();307$quiz->course = $courseid;308$quiz->name = 'My Quiz';309$quiz->coursemodule = $cmid;310// ... other quiz fields ...311312$quizid = quiz_add_instance($quiz, null);313314// Update course module with instance ID315$DB->set_field('course_modules', 'instance', $quizid, ['id' => $cmid]);316course_add_cm_to_section($courseid, $cmid, 0);317```318319### Access Restrictions (Groups/Availability)320321```php322// Restrict activity to specific user via group323$groupname = 'activity_' . $activityid . '_user_' . $userid;324325// Create or get group326if (!$groupid = $DB->get_field('groups', 'id', ['courseid' => $courseid, 'name' => $groupname])) {327 $groupdata = (object)[328 'courseid' => $courseid,329 'name' => $groupname,330 'timecreated' => time(),331 'timemodified' => time()332 ];333 $groupid = $DB->insert_record('groups', $groupdata);334}335336// Add user to group337if (!$DB->record_exists('groups_members', ['groupid' => $groupid, 'userid' => $userid])) {338 $DB->insert_record('groups_members', (object)[339 'groupid' => $groupid,340 'userid' => $userid,341 'timeadded' => time()342 ]);343}344345// Set availability condition346$restriction = [347 'op' => '&',348 'show' => false,349 'c' => [350 [351 'type' => 'group',352 'id' => $groupid353 ]354 ],355 'showc' => [false]356];357358$DB->set_field('course_modules', 'availability', json_encode($restriction), ['id' => $cmid]);359```360361### Random Question Selection with Tags362363```php364private static function get_random_questions($categoryid, $tagname, $limit) {365 global $DB;366367 $sql = "SELECT q.id368 FROM {question} q369 INNER JOIN {question_versions} qv ON qv.questionid = q.id370 INNER JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid371 INNER JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid372 JOIN {tag_instance} ti ON ti.itemid = q.id373 JOIN {tag} t ON t.id = ti.tagid374 WHERE LOWER(t.name) = :tagname375 AND qc.id = :categoryid376 AND ti.itemtype = 'question'377 AND q.qtype = 'multichoice'";378379 $qids = $DB->get_fieldset_sql($sql, [380 'categoryid' => $categoryid,381 'tagname' => strtolower($tagname)382 ]);383384 shuffle($qids);385 return array_slice($qids, 0, $limit);386}387```388389## Testing Your API390391### 1. Via Moodle Web Services Test Client3923931. Enable web services: **Site administration > Advanced features**3942. Enable REST protocol: **Site administration > Plugins > Web services > Manage protocols**3953. Create service: **Site administration > Server > Web services > External services**3964. Test function: **Site administration > Development > Web service test client**397398### 2. Via curl399400```bash401# Get token first402curl -X POST "https://yourmoodle.com/login/token.php" \403 -d "username=admin" \404 -d "password=yourpassword" \405 -d "service=moodle_mobile_app"406407# Call your API408curl -X POST "https://yourmoodle.com/webservice/rest/server.php" \409 -d "wstoken=YOUR_TOKEN" \410 -d "wsfunction=local_yourplugin_your_api_name" \411 -d "moodlewsrestformat=json" \412 -d "userid=2" \413 -d "courseid=3"414```415416### 3. Via JavaScript (AJAX)417418```javascript419require(['core/ajax'], function(ajax) {420 var promises = ajax.call([{421 methodname: 'local_yourplugin_your_api_name',422 args: {423 userid: 2,424 courseid: 3425 }426 }]);427428 promises[0].done(function(response) {429 console.log('Success:', response);430 }).fail(function(error) {431 console.error('Error:', error);432 });433});434```435436## Common Pitfalls & Solutions437438### 1. "Function not found" Error439**Solution**:440- Purge caches: **Site administration > Development > Purge all caches**441- Verify function name in services.php matches exactly442- Check namespace and class name are correct443444### 2. "Invalid parameter value detected"445**Solution**:446- Ensure parameter types match between definition and usage447- Check required vs optional parameters448- Validate nested structure definitions449450### 3. SQL Injection Vulnerabilities451**Solution**:452- Always use placeholder parameters (`:paramname`)453- Never concatenate user input into SQL strings454- Use Moodle's database methods: `get_record()`, `get_records()`, etc.455456### 4. Permission Denied Errors457**Solution**:458- Call `self::validate_context($context)` early in execute()459- Check required capabilities match user's permissions460- Verify user has role assignments in the context461462### 5. Transaction Deadlocks463**Solution**:464- Keep transactions short465- Always commit or rollback in finally blocks466- Avoid nested transactions467468## Debugging Checklist469470- [ ] Check Moodle debug mode: **Site administration > Development > Debugging**471- [ ] Review web services logs: **Site administration > Reports > Logs**472- [ ] Check custom log files in `$CFG->dataroot/local_yourplugin/`473- [ ] Verify database queries using `$DB->set_debug(true)`474- [ ] Test with admin user to rule out permission issues475- [ ] Clear browser cache and Moodle caches476- [ ] Check PHP error logs on server477478## Plugin Structure Checklist479480```481local/yourplugin/482├── version.php # Plugin version and metadata483├── db/484│ ├── services.php # External service definitions485│ └── access.php # Capability definitions (optional)486├── classes/487│ └── external/488│ ├── your_api_name.php # External API implementation489│ └── another_api.php # Additional APIs490├── lang/491│ └── en/492│ └── local_yourplugin.php # Language strings493└── tests/494 └── external_test.php # Unit tests (optional but recommended)495```496497## Examples from Real Implementation498499### Simple Read API (Get Quiz Attempts)500501```php502<?php503namespace local_userlog\external;504505defined('MOODLE_INTERNAL') || die();506require_once("$CFG->libdir/externallib.php");507508use external_api;509use external_function_parameters;510use external_single_structure;511use external_value;512513class get_quiz_attempts extends external_api {514 public static function execute_parameters() {515 return new external_function_parameters([516 'userid' => new external_value(PARAM_INT, 'User ID'),517 'courseid' => new external_value(PARAM_INT, 'Course ID')518 ]);519 }520521 public static function execute($userid, $courseid) {522 global $DB;523524 self::validate_parameters(self::execute_parameters(), [525 'userid' => $userid,526 'courseid' => $courseid527 ]);528529 $sql = "SELECT COUNT(*) AS quiz_attempts530 FROM {quiz_attempts} qa531 JOIN {quiz} q ON qa.quiz = q.id532 WHERE qa.userid = :userid AND q.course = :courseid";533534 $attempts = $DB->get_field_sql($sql, [535 'userid' => $userid,536 'courseid' => $courseid537 ]);538539 return ['quiz_attempts' => (int)$attempts];540 }541542 public static function execute_returns() {543 return new external_single_structure([544 'quiz_attempts' => new external_value(PARAM_INT, 'Total number of quiz attempts')545 ]);546 }547}548```549550### Complex Write API (Create Quiz from Categories)551552See attached `create_quiz_from_categories.php` for a comprehensive example including:553- Multiple database insertions554- Course module creation555- Quiz instance configuration556- Random question selection with tags557- Group-based access restrictions558- Extensive error logging559- Transaction management560561## Quick Reference: Common Moodle Tables562563| Table | Purpose |564|-------|---------|565| `{user}` | User accounts |566| `{course}` | Courses |567| `{course_modules}` | Activity instances in courses |568| `{modules}` | Available activity types (quiz, forum, etc.) |569| `{quiz}` | Quiz configurations |570| `{quiz_attempts}` | Quiz attempt records |571| `{question}` | Question bank |572| `{question_categories}` | Question categories |573| `{grade_items}` | Gradebook items |574| `{grade_grades}` | Student grades |575| `{groups}` | Course groups |576| `{groups_members}` | Group memberships |577| `{logstore_standard_log}` | Activity logs |578579## Additional Resources580581- [Moodle External API Documentation](https://moodledev.io/docs/5.2/apis/subsystems/external/functions)582- [Moodle Coding Style](https://moodledev.io/general/development/policies/codingstyle)583- [Moodle Database API](https://moodledev.io/docs/5.2/apis/core/dml)584- [Web Services API Documentation](https://moodledev.io/docs/5.2/apis/subsystems/external)585586## Guidelines587588- Always validate input parameters using `validate_parameters()`589- Check user context and capabilities before operations590- Use parameterized SQL queries (never string concatenation)591- Implement comprehensive error handling and logging592- Follow Moodle naming conventions (lowercase, underscores)593- Document all parameters and return values clearly594- Test with different user roles and permissions595- Consider transaction safety for write operations596- Purge caches after service registration changes597- Keep API methods focused and single-purpose598
Full transparency — inspect the skill content before installing.