Transform Learning with a Powerful Enterprise LMS

Learn more
Moodle

Moodle Privacy API Provider: Implementing GDPR Support in a Plugin

Which privacy interfaces a Moodle plugin needs, a complete provider for a local plugin, how export and delete calls work, why core_userlist_provider is required and the core tests that check it.

Learn more about this topic with AI

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

Diagram of a Moodle privacy provider class linking plugin tables to the export and delete requests of the Data privacy tool

A Moodle privacy API provider is a class named provider in your plugin's \{component}\privacy namespace, at classes/privacy/provider.php. It tells Moodle's core_privacy subsystem what personal data the plugin stores, and lets the Data privacy tool find, export and delete that data when a user makes a subject access request or asks to be forgotten. A plugin that stores nothing implements null_provider; a plugin that stores user data implements a metadata provider, a request provider and, since Moodle 3.4.6 and 3.5.3, core_userlist_provider.

This explainer walks through which interfaces to implement, a complete provider for a small local plugin, how the export and delete calls are made, and the core unit tests that fail when the provider is incomplete. It is written for developers shipping plugins to sites that must handle GDPR requests, and for administrators checking whether a third-party plugin is ready for them.

Checked against the Moodle 5.3.0 source (public/privacy/classes/, public/privacy/tests/privacy/provider_test.php and tool_dataprivacy) and the Privacy API developer documentation, as of October 2026. Paths below are relative to the public/ web root used since Moodle 5.1.

What the Privacy API is for

The GDPR gives people the right to know what personal data is held about them, to get a copy of it and to have it erased. Moodle answers those requests centrally through tool_dataprivacy, which administrators find under Site administration > Users > Privacy and policies (Data requests, Data registry, Data deletion and Plugin privacy registry). The tool does not know your plugin's tables. It asks \core_privacy\manager, and the manager asks each component's provider.

The request providers are responsible for six jobs, as the developer documentation lists them:

  1. finding the contexts in which a user has personal data
  2. exporting that user's data from those contexts
  3. deleting that user's data from those contexts
  4. finding which users have personal data in one context
  5. deleting the data of several users in that context
  6. deleting all users' data in a context, when its retention period expires

Personal data is broader than people expect. The documentation counts any record with a userid, including its timestamps and IDs, any free text the user typed, and data stored about a user by someone else, such as a teacher's comments and ratings on a student's submission.

Choosing the interfaces

Your pluginImplement
Stores no personal data: no user-linked tables, no user preferences, sends nothing to external systems\core_privacy\local\metadata\null_provider
Stores user data in its own tables or in core subsystems (files, ratings, comments, tags)\core_privacy\local\metadata\provider, \core_privacy\local\request\plugin\provider and \core_privacy\local\request\core_userlist_provider
Stores site-wide user preferencesAlso \core_privacy\local\request\user_preference_provider
Is a subplugin, such as an assignsubmission pluginThe metadata provider plus the interface the parent plugin defines, which extends \core_privacy\local\request\plugin\subplugin_provider
Is called through a subsystem, such as a plagiarism pluginThe metadata provider plus the subsystem's interface, such as \core_plagiarism\privacy\plugin_provider

The null_provider case is short. Implement get_reason() and return a language string key, usually privacy:metadata, with a one-line explanation in your language file. Do not use it for a block or filter that causes data to be stored elsewhere: the documentation's example is the Comments block, which stores nothing itself but is the only component that knows how its comments map to users.

Describing your data with get_metadata()

get_metadata(collection $collection): collection describes every kind of personal data the plugin holds. The \core_privacy\local\metadata\collection class in 5.3 offers:

  • add_database_table($name, array $privacyfields, $summary) for each table that holds user data, listing the fields that contain it
  • add_subsystem_link($name, array $privacyfields, $summary) when you store data through a core subsystem, for example core_files
  • add_external_location_link($name, array $privacyfields, $summary) when you send data to an outside service, describing the type of destination and the fields sent
  • add_user_preference($name, $summary) for site-wide preferences; for a preference name that includes a variable, register only the common prefix
  • add_plugintype_link() when your plugin delegates storage to its own subplugins

Every field and summary is a language string key in your component, not literal text. The administrator sees these strings on the Plugin privacy registry page, so write them for a privacy officer, not for a developer.

A complete provider for a local plugin

The example below is for a hypothetical local_coursenotes plugin with one table, local_coursenotes, holding courseid, userid, note and timecreated. Notes belong to a course, so the provider works in course contexts. It follows the patterns in the developer documentation; it has not been run on a live site, so test it on staging first.

<?php
// local/coursenotes/classes/privacy/provider.php
namespace local_coursenotes\privacy;

use core_privacy\local\metadata\collection;
use core_privacy\local\request\approved_contextlist;
use core_privacy\local\request\approved_userlist;
use core_privacy\local\request\contextlist;
use core_privacy\local\request\transform;
use core_privacy\local\request\userlist;
use core_privacy\local\request\writer;

class provider implements
        \core_privacy\local\metadata\provider,
        \core_privacy\local\request\plugin\provider,
        \core_privacy\local\request\core_userlist_provider {

    public static function get_metadata(collection $collection): collection {
        $collection->add_database_table('local_coursenotes', [
            'userid' => 'privacy:metadata:local_coursenotes:userid',
            'note' => 'privacy:metadata:local_coursenotes:note',
            'timecreated' => 'privacy:metadata:local_coursenotes:timecreated',
        ], 'privacy:metadata:local_coursenotes');
        return $collection;
    }

    public static function get_contexts_for_userid(int $userid): contextlist {
        $sql = "SELECT ctx.id
                  FROM {local_coursenotes} n
                  JOIN {context} ctx
                    ON ctx.instanceid = n.courseid AND ctx.contextlevel = :contextlevel
                 WHERE n.userid = :userid";
        $contextlist = new contextlist();
        $contextlist->add_from_sql($sql, ['contextlevel' => CONTEXT_COURSE, 'userid' => $userid]);
        return $contextlist;
    }

    public static function get_users_in_context(userlist $userlist) {
        $context = $userlist->get_context();
        if (!$context instanceof \context_course) {
            return;
        }
        $sql = "SELECT userid FROM {local_coursenotes} WHERE courseid = :courseid";
        $userlist->add_from_sql('userid', $sql, ['courseid' => $context->instanceid]);
    }

    public static function export_user_data(approved_contextlist $contextlist) {
        global $DB;
        $userid = $contextlist->get_user()->id;
        foreach ($contextlist->get_contexts() as $context) {
            if ($context->contextlevel != CONTEXT_COURSE) {
                continue;
            }
            $records = $DB->get_records('local_coursenotes',
                ['courseid' => $context->instanceid, 'userid' => $userid], 'timecreated ASC');
            if (!$records) {
                continue;
            }
            $notes = [];
            foreach ($records as $record) {
                $notes[] = (object) [
                    'note' => $record->note,
                    'timecreated' => transform::datetime($record->timecreated),
                ];
            }
            writer::with_context($context)->export_data(
                [get_string('pluginname', 'local_coursenotes')], (object) ['notes' => $notes]);
        }
    }

    public static function delete_data_for_all_users_in_context(\context $context) {
        global $DB;
        if ($context->contextlevel != CONTEXT_COURSE) {
            return;
        }
        $DB->delete_records('local_coursenotes', ['courseid' => $context->instanceid]);
    }

    public static function delete_data_for_user(approved_contextlist $contextlist) {
        global $DB;
        $userid = $contextlist->get_user()->id;
        foreach ($contextlist->get_contexts() as $context) {
            if ($context->contextlevel == CONTEXT_COURSE) {
                $DB->delete_records('local_coursenotes',
                    ['courseid' => $context->instanceid, 'userid' => $userid]);
            }
        }
    }

    public static function delete_data_for_users(approved_userlist $userlist) {
        global $DB;
        $context = $userlist->get_context();
        if (!$context instanceof \context_course) {
            return;
        }
        [$insql, $params] = $DB->get_in_or_equal($userlist->get_userids(), SQL_PARAMS_NAMED);
        $params['courseid'] = $context->instanceid;
        $DB->delete_records_select('local_coursenotes', "courseid = :courseid AND userid {$insql}", $params);
    }
}

Add the matching strings to lang/en/local_coursenotes.php: privacy:metadata:local_coursenotes describing the table, and one string per listed field. New to local plugins? Our Moodle local plugin tutorial covers the folder layout, version.php and db/install.xml this example assumes.

How the request methods are called

The method contracts are easy to get subtly wrong. These are the rules from the documentation and the interface definitions:

  • Detection is SQL, not PHP loops. contextlist::add_from_sql($sql, $params) takes a query that returns context IDs only. userlist::add_from_sql($fieldname, $sql, $params) takes the name of the user ID column first. A subject access request touches every component on the site, so these queries must be fast.
  • get_users_in_context() returns nothing. It fills the userlist it is given and must look at that single context only, not its children.
  • Delete-for-context is called for every expiring context. When a course expires, the method is called for the course context and then for each module and block context inside it, including contexts your plugin never stored data in. Check the context level and instance and return early, as the example does.
  • Deleting can mean overwriting. When other users' records depend on the row, such as a forum reply under a post, the documentation allows you to overwrite the personal content and keep the structure.
  • Export through the writer. writer::with_context($context) returns a content_writer with export_data(), export_related_data(), export_metadata(), export_area_files() and export_user_preference(). Pass a subcontext array to structure the output, and run text fields that contain embedded files through rewrite_pluginfile_urls().
  • Format values for people. \core_privacy\local\request\transform provides datetime(), date(), yesno(), user() and percentage(), so exports show readable dates and flags rather than raw integers.

Why core_userlist_provider is required

The original 2018 API worked user by user. Support for finding and removing several users in one context was added in MDL-62560 for Moodle 3.6, 3.5.3 and 3.4.6, so that data can be removed for users matching role criteria and expired by role. Any plugin implementing \core_privacy\local\request\plugin\provider must now also implement core_userlist_provider, with get_users_in_context() and delete_data_for_users().

There is a trap here. \core_privacy\manager::component_is_compliant() in 5.3 only checks for the metadata provider and a data provider, so a plugin without the userlist methods still shows as compliant in the Plugin privacy registry. The core unit test test_userdata_provider_implements_userlist is what fails. Per-role expiry and multi-user deletes then skip your data silently, which is the worst outcome for a privacy feature.

Testing a provider

Moodle core ships privacy/tests/privacy/provider_test.php, whose tests are tagged @group plugin_checks. Its data providers walk every installed component, so it tests third-party plugins too. Its checks include:

  • test_all_providers_compliant: the provider class exists and implements the required interfaces
  • test_null_provider: get_reason() returns a string that exists
  • test_metadata_provider: every declared table exists, every subsystem and plugin type link is real, and every summary and field string is defined
  • test_userdata_provider_implements_userlist: user data providers also implement core_userlist_provider
  • test_table_coverage: every table in your db/install.xml that has a userid field, or a foreign key to user.id, is declared with add_database_table()

For your own tests, extend \core_privacy\tests\provider_testcase, not advanced_testcase; the documentation notes that the advanced test case does not reset the privacy content writer between tests. Create data for two users, call each method, and assert that only the right user's rows are exported or deleted. The developer documentation also provides a CLI script for development that walks every component and runs a test export, but it is not a substitute for unit tests.

Common gaps we see in third-party plugins

  • A null_provider on a plugin that logs. A table with userid, an IP address column or a stored user preference rules out the null provider. The table coverage test catches the first case; the other two need a manual review.
  • External services left undeclared. Plugins that call an AI service, video host or CRM must declare it with add_external_location_link(). Moodle cannot export or delete data held on the remote side, but the administrator needs to know it is sent.
  • Subsystem data with no link. If users attach files, the plugin must declare core_files and export the file areas with export_area_files().
  • Wrong context level. Storing data against the system context when it belongs to a course means it never expires with the course retention policy set in the Data registry.
  • Provider not updated with the schema. A new column with free text added in an upgrade step needs a new field in get_metadata() and in the export.

For the wider picture of how plugins fit together, see the Moodle guide.

How we can help

We audit and write privacy providers as part of our Moodle plugin development work, including retrofitting older third-party plugins that fail the core privacy tests. If you are preparing a site for GDPR requests across many plugins, our Moodle services team can review the full plugin list.

Frequently asked questions

Where does the Moodle privacy provider class go?

In classes/privacy/provider.php inside your plugin, as a class called provider in the \{component}\privacy namespace, for example \local_coursenotes\privacy\provider.

When can a Moodle plugin use the null_provider?

Only when the plugin has no database tables that store user data (including IP addresses), no user preferences and no external links that send user data elsewhere. A plugin that causes data to be stored in a core subsystem, such as comments, cannot use it.

Is core_userlist_provider mandatory?

Yes, for any plugin that implements \core_privacy\local\request\plugin\provider, since Moodle 3.4.6 and 3.5.3. It adds get_users_in_context() and delete_data_for_users(), which Moodle uses for per-role expiry and multi-user deletion.

How do I check that a plugin's privacy provider is complete?

Run Moodle's core privacy provider tests in privacy/tests/privacy/provider_test.php, which cover every installed component, and check the plugin on Site administration > Users > Privacy and policies > Plugin privacy registry. Add your own tests that extend \core_privacy\tests\provider_testcase.

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. Privacy API - Moodle developer documentation
  2. Privacy API utilities - Moodle developer documentation
  3. Moodle 5.3 source: public/privacy/classes/local/metadata/collection.php - Moodle HQ
  4. Moodle 5.3 source: public/privacy/tests/privacy/provider_test.php - Moodle HQ
  5. MDL-62560: support for multiple users in one context - Moodle Tracker
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