Transform Learning with a Powerful Enterprise LMS

Learn more
Moodle

How to Create a Moodle Web Service Function with core_external

Build a complete Moodle web service function with core_external: declare it in db/services.php, describe parameters and returns, add context and capability checks, call it over REST and test it.

Learn more about this topic with AI

Gemini opens with the prompt copied - paste it into the chat to start.

Illustration of a Moodle plugin exposing a web service function through core_external, with parameter and return descriptions

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:

FilePurpose
local/progressapi/version.phpComponent name and version; bump it to register changes
local/progressapi/lang/en/local_progressapi.phpThe pluginname string
local/progressapi/db/services.phpDeclares the function and, optionally, a pre-built service
local/progressapi/classes/external/get_course_progress.phpThe external function class
local/progressapi/tests/external/get_course_progress_test.phpPHPUnit 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 calls execute().
  • type: read or write, declaring whether the function only reads data or also changes it.
  • ajax: set to true so your own AMD modules can call the function through core/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. loginrequired defaults to true when omitted.
  • services: optional list of services to add the function to, for example MOODLE_OFFICIAL_MOBILE_SERVICE if 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_REQUIRED unless you say otherwise. VALUE_OPTIONAL cannot be used for top-level parameters, because some protocols are strict about argument counts; use VALUE_DEFAULT with a default, as userid does here, or nest the optional values inside an external_single_structure.
  • Order matters. The arguments of execute() must be in the same order as the keys in execute_parameters().
  • Nulls in returns. external_value takes an $allownull argument after the default. clean_returnvalue() passes a null through only when the description allows it, which is why progress sets NULL_ALLOWED explicitly.

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:

  1. validate_parameters() checks the raw input against the description, applies defaults and cleans the values to their PARAM_* types. Always use its return value, never the raw arguments.
  2. 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 calls require_login() for the course and sets $PAGE's context. Do not call require_login() or $PAGE->set_context() yourself in an external function.
  3. require_capability() is the actual permission check. Reading your own progress needs only course access; reading someone else's needs report/progress:view, the capability behind the core activity completion report.
  4. Exceptions are converted into error responses by the web service servers. invalid_parameter_exception is 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:

  1. Enable web services on the Advanced features page of Site administration.
  2. Enable the REST protocol in Site administration > Server > Web services > Manage protocols.
  3. Enable the pre-built "Progress API" service, or add the function to a custom service, in Site administration > Server > Web services > External services.
  4. 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.
  5. 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

  1. Writing a new service - Moodle HQ
  2. External function definitions - Moodle HQ
  3. Service creation (custom services) - Moodle HQ
  4. external_api class (Moodle 5.3.0) - Moodle HQ
  5. Moodle 5.3 upgrade notes (core_external deprecations) - Moodle HQ
Add as a preferred source on Google

Add All-in-One LMS as a preferred source to see more of our articles in Google’s Top Stories.

Learn more about this topic with AI

Gemini opens with the prompt copied - paste it into the chat to start.

Keep reading

Before you go - get a free LMS audit

Tell us which LMS you use and what is not working. An engineer replies within one business day.

Choose which optional cookies we may use. You can change this at any time from "Cookie preferences" in the footer. Cookie policy · Privacy policy