To create a web service function in Moodle, you declare it in your plugin's db/services.php, write a class in the plugin's external namespace that extends \core_external\external_api with three static methods (execute_parameters(), execute() and execute_returns()), then bump the plugin version so the upgrade registers it. Inside execute() you always validate the parameters, validate the context and check a capability before touching data.
This is a complete Moodle create web service function walkthrough. It builds one read function, local_progressapi_get_course_progress, which returns a learner's course completion percentage. It covers the files, the parameter and return descriptions, the security checks, a PHPUnit test and how to call it over REST. Checked against the Moodle 5.3.0 source (public/lib/external, UPGRADING.md) and the Moodle developer documentation for the External API, in October 2026. The code below follows Moodle coding standards but has not been run on a live site; test it on staging first.
One version note before you start: in Moodle 5.3 the legacy global classes such as \external_api, \external_value and \external_function_parameters are formally deprecated in favour of the namespaced \core_external\... classes that arrived in Moodle 4.2. New code should only use the namespaced ones.
What you are building
A web service function is a named entry point that Moodle exposes through its web service protocols (REST, SOAP) and through AJAX for its own JavaScript. Each name maps to one PHP class. Moodle reads the declarations from db/services.php during install or upgrade and stores them in the external_functions table; at call time external_api::external_function_info() loads the class and checks that the three required methods exist.
Our example plugin is local_progressapi. It has four files you write by hand, plus a test:
| File | Purpose |
|---|---|
local/progressapi/version.php | Component name and version; bump it to register changes |
local/progressapi/lang/en/local_progressapi.php | The pluginname string |
local/progressapi/db/services.php | Declares the function and, optionally, a pre-built service |
local/progressapi/classes/external/get_course_progress.php | The external function class |
local/progressapi/tests/external/get_course_progress_test.php | PHPUnit test |
On Moodle 5.1 and later the code base keeps plugins under public/, so on disk the plugin lives at public/local/progressapi. Paths in this article are written relative to that web root, as the developer docs do.
Name the function correctly
Moodle requires the function name to be the full frankenstyle component followed by a method name: [component]_[methodname]. The naming policy asks for the method name in verb_noun form, with the verb usually one of get, create, update or delete and the noun usually plural. That gives local_progressapi_get_course_progress.
The class name follows the same pattern: local_progressapi\external\get_course_progress, autoloaded from classes/external/get_course_progress.php. One class per function keeps the method names fixed as execute, execute_parameters and execute_returns, which is what the documentation now recommends over the older externallib.php style.
Declare it in db/services.php
<?php
// local/progressapi/db/services.php
defined('MOODLE_INTERNAL') || die();
$functions = [
'local_progressapi_get_course_progress' => [
'classname' => 'local_progressapi\external\get_course_progress',
'description' => 'Returns the course completion percentage for a user.',
'type' => 'read',
'ajax' => true,
'capabilities' => 'report/progress:view',
],
];
$services = [
'Progress API' => [
'functions' => ['local_progressapi_get_course_progress'],
'restrictedusers' => 1,
'enabled' => 0,
'shortname' => 'local_progressapi',
'downloadfiles' => 0,
'uploadfiles' => 0,
],
];
What each key does, based on how external_function_info() reads the file in 5.3.0:
- classname: the autoloaded class. With no
methodname, Moodle callsexecute(). - type:
readorwrite, declaring whether the function only reads data or also changes it. - ajax: set to
trueso your own AMD modules can call the function throughcore/ajax. - capabilities: advisory only. It tells administrators which capabilities a service user needs; it does not enforce anything. Enforcement happens in your code.
- loginrequired and readonlysession: optional.
loginrequireddefaults to true when omitted. - services: optional list of services to add the function to, for example
MOODLE_OFFICIAL_MOBILE_SERVICEif the Moodle App should use it.
The $services block is optional. It creates a pre-built service an administrator can enable for an integration. Administrators cannot add functions to or remove them from a pre-built service, and shortname is needed when restrictedusers is set so the service can be configured in the web UI. Moodle 5.3 also adds an allowcorsrequests key for AJAX functions that should answer cross-origin requests; it only applies to calls without a Moodle session and functions that do not require login, so leave it out for anything that reads user data.
Describe the parameters and return values
Every function has a parameter description and a return description built from four classes: external_function_parameters for the top level, external_single_structure for an object, external_multiple_structure for a list, and external_value for a scalar with a PARAM_* type. The web service servers use these descriptions to validate input, clean output and generate the API documentation page.
<?php
// local/progressapi/classes/external/get_course_progress.php
namespace local_progressapi\external;
use core_external\external_api;
use core_external\external_function_parameters;
use core_external\external_single_structure;
use core_external\external_value;
/**
* Returns a user's course completion percentage.
*
* @package local_progressapi
* @copyright 2027 Your Name
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class get_course_progress extends external_api {
public static function execute_parameters(): external_function_parameters {
return new external_function_parameters([
'courseid' => new external_value(PARAM_INT, 'Course id'),
'userid' => new external_value(PARAM_INT, 'User id, 0 for the current user', VALUE_DEFAULT, 0),
]);
}
public static function execute_returns(): external_single_structure {
return new external_single_structure([
'courseid' => new external_value(PARAM_INT, 'Course id'),
'userid' => new external_value(PARAM_INT, 'User id'),
'progress' => new external_value(PARAM_FLOAT,
'Completion percentage, null when completion is not tracked',
VALUE_REQUIRED, null, NULL_ALLOWED),
]);
}
}
Three rules catch most first-time mistakes:
- Required, optional and default. A value is
VALUE_REQUIREDunless you say otherwise.VALUE_OPTIONALcannot be used for top-level parameters, because some protocols are strict about argument counts; useVALUE_DEFAULTwith a default, asuseriddoes here, or nest the optional values inside anexternal_single_structure. - Order matters. The arguments of
execute()must be in the same order as the keys inexecute_parameters(). - Nulls in returns.
external_valuetakes an$allownullargument after the default.clean_returnvalue()passes a null through only when the description allows it, which is whyprogresssetsNULL_ALLOWEDexplicitly.
Write execute() with the security checks
Add this method to the same class. It uses \core_completion\progress::get_course_progress_percentage(), which returns null when completion is disabled, the user is not tracked or no activities have completion.
public static function execute(int $courseid, int $userid = 0): array {
global $USER;
[
'courseid' => $courseid,
'userid' => $userid,
] = self::validate_parameters(self::execute_parameters(), [
'courseid' => $courseid,
'userid' => $userid,
]);
$course = get_course($courseid);
$context = \context_course::instance($course->id);
self::validate_context($context);
if (empty($userid)) {
$userid = (int) $USER->id;
}
if ($userid !== (int) $USER->id) {
require_capability('report/progress:view', $context);
}
if (!is_enrolled($context, $userid)) {
throw new \invalid_parameter_exception('The user is not enrolled in this course.');
}
$progress = \core_completion\progress::get_course_progress_percentage($course, $userid);
return [
'courseid' => (int) $course->id,
'userid' => $userid,
'progress' => $progress,
];
}
Why each step is there:
validate_parameters()checks the raw input against the description, applies defaults and cleans the values to theirPARAM_*types. Always use its return value, never the raw arguments.validate_context()is required before you work with data in any context. In 5.3.0 it enforces any context restriction set for the token, then callsrequire_login()for the course and sets$PAGE's context. Do not callrequire_login()or$PAGE->set_context()yourself in an external function.require_capability()is the actual permission check. Reading your own progress needs only course access; reading someone else's needsreport/progress:view, the capability behind the core activity completion report.- Exceptions are converted into error responses by the web service servers.
invalid_parameter_exceptionis now\core\exception\invalid_parameter_exception, with a global alias, so either name works in 5.3.
For write functions, wrap the changes in $DB->start_delegated_transaction() and call allow_commit() at the end, so that a failure part way through a batch rolls everything back. Return arrays, not objects: the server validates the response against execute_returns(), silently drops keys that are not described, and returns an error when a required key is missing or has the wrong type.
Register it and run the upgrade
Moodle only reads db/services.php when the plugin version changes. After adding or changing a function, increase $plugin->version in version.php and run the upgrade, either by visiting the notifications page or from the command line:
php admin/cli/upgrade.php --non-interactive
In 5.3 the admin/cli scripts sit at the project root, outside public/. If the function does not appear after the upgrade, purge caches with php admin/cli/purge_caches.php and check for a typo in classname; external_function_info() throws a coding_exception naming the missing class or method.
The function then appears in Site administration > Server > Web services > API Documentation, with the parameter and return structures generated from your descriptions.
Expose it to a client and call it over REST
For an external system to call the function you need web services enabled, a protocol, a service, a user and a token:
- Enable web services on the Advanced features page of Site administration.
- Enable the REST protocol in Site administration > Server > Web services > Manage protocols.
- Enable the pre-built "Progress API" service, or add the function to a custom service, in Site administration > Server > Web services > External services.
- Create a dedicated service user, give it a role with the capabilities the function checks, and authorise it for the service if the service has restricted users.
- Create a token for that user and service in Site administration > Server > Web services > Manage tokens.
Then call the REST server. Moodle reads wstoken, wsfunction and moodlewsrestformat (xml by default, or json) and passes the rest as function parameters:
curl https://lms.example.com/webservice/rest/server.php \
-d wstoken=YOUR_TOKEN \
-d wsfunction=local_progressapi_get_course_progress \
-d moodlewsrestformat=json \
-d courseid=42 \
-d userid=1234
A successful call returns {"courseid":42,"userid":1234,"progress":62.5}, with the figures from your site. If you get "Invalid token" or an access control exception instead, work through our checklist for Moodle web service token and access errors; most causes are a disabled service, a missing capability on the service user or an IP restriction on the token. For the wider picture of designing APIs on top of an LMS, see our LMS REST API guide.
From Moodle's own JavaScript, the same function is available through AJAX because ajax is true:
import {call as fetchMany} from 'core/ajax';
const [result] = await Promise.all(fetchMany([{
methodname: 'local_progressapi_get_course_progress',
args: {courseid: 42},
}]));
Test it with PHPUnit
Put one test class per function in tests/external/. In 5.3.0 the base class is \core_external\tests\externallib_testcase; the old externallib_advanced_testcase name still works as an alias from webservice/tests/helpers.php. Call execute() directly, then run the result through external_api::clean_returnvalue() so the test also checks your return description.
<?php
namespace local_progressapi\external;
use core_external\external_api;
/**
* @covers \local_progressapi\external\get_course_progress
*/
final class get_course_progress_test extends \core_external\tests\externallib_testcase {
public function test_other_user_needs_capability(): void {
$this->resetAfterTest();
$course = $this->getDataGenerator()->create_course(['enablecompletion' => 1]);
$student = $this->getDataGenerator()->create_and_enrol($course, 'student');
$other = $this->getDataGenerator()->create_and_enrol($course, 'student');
$this->setUser($other);
$this->expectException(\required_capability_exception::class);
get_course_progress::execute($course->id, $student->id);
}
public function test_own_progress_is_returned(): void {
$this->resetAfterTest();
$course = $this->getDataGenerator()->create_course(['enablecompletion' => 1]);
$student = $this->getDataGenerator()->create_and_enrol($course, 'student');
$this->setUser($student);
$result = get_course_progress::execute($course->id);
$result = external_api::clean_returnvalue(get_course_progress::execute_returns(), $result);
$this->assertEquals($student->id, $result['userid']);
}
}
Test the failure paths as carefully as the happy path: a missing capability, a user who is not enrolled and a course id that does not exist. Those are the cases an integration partner will hit first.
Change or retire a function later
Treat a published function as a public API. Integrations and the Moodle App may depend on its exact parameters and return keys, so add new optional values with VALUE_DEFAULT rather than changing existing ones, and create a new function when the contract has to break.
To deprecate a function, the documented process is different from Moodle's standard deprecation: add a static execute_is_deprecated() method that returns true, so the framework flags the function, and add an @deprecated PHPDoc tag with the version and tracker issue. Do not use the \core\attribute\deprecated attribute or the \core\deprecation emit methods on external functions.
If your plugin must also run on Moodle versions before 4.2, the docs say to require_once lib/externallib.php and extend the global external_api class. Given the 5.3 deprecation, the cleaner option is a separate branch of your plugin for 4.2 and later; see our Moodle guide for how supported branches line up.
How we can help
We build and review Moodle web services and the integrations that call them: HR and CRM sync, reporting feeds and mobile back ends, with capability design, token handling and PHPUnit coverage. See our Moodle development services or contact us with the integration you need.
Frequently asked questions
Where do I declare a web service function in Moodle?
In the db/services.php file of your plugin, in the $functions array. The key is the function name in the form component_methodname, and the classname points to an autoloaded class in your plugin's external namespace.
Why does my new web service function not appear in Moodle?
Moodle only reads db/services.php during install or upgrade. Increase the version in version.php, run the upgrade, then purge caches. If it still fails, check the classname value and that the class has execute(), execute_parameters() and execute_returns().
Can I use VALUE_OPTIONAL for a top-level parameter?
No. Optional values cannot be top-level parameters because some web service protocols are strict about argument counts. Use VALUE_DEFAULT with a default value, or nest the optional values inside an external_single_structure.
Is external_api deprecated in Moodle 5.3?
The global \external_api class and the related legacy \external_* classes and functions are deprecated in 5.3. The namespaced \core_external\external_api, available since Moodle 4.2, is the class to extend.
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
- Writing a new service - Moodle HQ
- External function definitions - Moodle HQ
- Service creation (custom services) - Moodle HQ
- external_api class (Moodle 5.3.0) - Moodle HQ
- Moodle 5.3 upgrade notes (core_external deprecations) - Moodle HQ
Learn more about this topic with AI
Gemini opens with the prompt copied - paste it into the chat to start.