Sometimes you need Moodle to do something extra when a particular thing happens: add every new user to a cohort, push a course completion to an external system, or send a tailored notification when someone enrolls. The wrong way to do this is to edit a core file. That change is invisible to the next person, it breaks on upgrade, and it puts you outside supported Moodle.
The right way is an event observer. Moodle fires a typed event for almost everything that happens in the system, and any plugin can register a small handler that runs when a given event is dispatched. Your code lives entirely in your own plugin, so it survives upgrades and is easy to reason about.
This guide walks through building a minimal observer plugin from scratch, using a real example: when a user is created, read a profile field and add them to the cohort that matches it. Routing users into the right cohort by a characteristic such as region or department is genuinely useful automation, since that cohort can then drive enrollments through cohort sync, target audiences, or reports. Core has no built-in setting for it, and the event hands you the user you need to act on.
What an event is
When something significant happens, core triggers an event object, for example \core\event\user_created or \core\event\course_completed. The event carries structured data: who did it, which object it concerns, and a small bag of extra fields. You can browse every event your site fires under Site administration, Reports, Events list. That page is the canonical list of event class names for your exact version, which matters because the names have shifted over the years.
An observer is a static method you register against an event name. Moodle calls it and passes the event object. That is the whole model.
The plugin skeleton
An observer needs to live in a plugin. A local plugin is the natural home for site-specific glue like this. The minimum is three files under local/autocohort/.
First, version.php, which tells Moodle the plugin exists and what it needs:
<?php
defined('MOODLE_INTERNAL') || die();
$plugin->component = 'local_autocohort';
$plugin->version = 2026110300;
$plugin->requires = 2024100700; // Moodle 4.5.
$plugin->maturity = MATURITY_STABLE;
$plugin->release = '1.0';
Second, a language file at lang/en/local_autocohort.php so the plugin has a name in the admin interface:
<?php
defined('MOODLE_INTERNAL') || die();
$string['pluginname'] = 'Automatic cohort assignment';
Third, the part that does the work: the event registration and the handler.
Registering the observer
Observers are declared in db/events.php. It returns an array of observer definitions. Each one ties an event name to a callback:
<?php
defined('MOODLE_INTERNAL') || die();
$observers = array(
array(
'eventname' => '\core\event\user_created',
'callback' => 'local_autocohortobserver::user_created',
),
);
Two optional keys are worth knowing about. priority controls the order when several observers watch the same event, higher first. internal controls when your handler runs, and it is worth understanding, so it gets its own section below. The default for both is usually what you want, so leave them out unless you have a reason.
The handler
The callback is a static method on a class Moodle can autoload. Place it at classes/observer.php, and the namespace follows from the plugin name. This handler reads a custom profile field, here one with the shortname region, and adds the user to the cohort whose idnumber matches that value:
<?php
namespace local_autocohort;
defined('MOODLE_INTERNAL') || die();
class observer {
/** Shortname of the custom profile field that drives cohort membership. */
const PROFILE_FIELD = 'region';
/**
* Add a newly created user to the cohort matching their profile field.
*
* @param \core\event\user_created $event
*/
public static function user_created(\core\event\user_created $event) {
global $CFG, $DB;
require_once($CFG->dirroot . '/user/profile/lib.php');
require_once($CFG->dirroot . '/cohort/lib.php');
// objectid is the id of the user that was just created. Custom profile
// data is already saved at this point in the standard creation paths.
$profile = profile_user_record($event->objectid, false);
$region = '';
if (isset($profile->{self::PROFILE_FIELD} {
$region = trim($profile->{self::PROFILE_FIELD});
}
if ($region === '') {
// No value set, nothing to do.
return;
}
// Convention: the cohort's idnumber matches the profile field value.
$cohort = $DB->get_record('cohort', array('idnumber' => $region;
if (!$cohort) {
return;
}
cohort_add_member($cohort->id, $event->objectid);
}
}
A few things to note. The event object is read only, so you take data out of it but never change it. The user id comes straight from $event->objectid, with no guessing and nothing to match against, because the event is about that exact user. Driving the cohort off the profile field value, by matching it against the cohort idnumber, means you never touch the code again to onboard a new region: an administrator just creates a cohort whose idnumber matches the field value. And cohort_add_member already ignores duplicates, so there is no need to check membership first.
One timing point is worth knowing. This works because custom profile data is saved before the user_created event fires in the standard creation paths. The add-user form, self-registration, and bulk upload all save the profile data first and then trigger the event. If you also want to react when an existing user’s field changes later, observe \core\event\user_updated as well, with its own handler.
To try it: add a custom profile field with the shortname region under Site administration, Users, User profile fields. Create a cohort whose idnumber is one of the field values, say emea. Then create a user with region set to emea. They land in the cohort automatically.
When your handler runs: the internal flag
The internal flag decides the moment your observer fires, and it is worth understanding before you build anything heavier than this.
When internal is true, which is the default if you omit it, Moodle calls your observer immediately, in the same database transaction as the action that triggered the event. That is exactly right for fast local work like adding a cohort member. It is quick, it is part of the same atomic operation, and if the surrounding action rolls back, your change rolls back with it.
When internal is false, Moodle buffers the event and dispatches it after the surrounding transaction commits. You want this for anything that should only happen once the triggering action is definitely saved, for example talking to an external system.
One rule follows from this: never do slow or external work directly inside an observer, whatever the flag. An HTTP call to another system inside a synchronous observer blocks the user’s request and can wedge a transaction. For that kind of work, have the observer do the cheap part, capture the ids it needs, and hand the slow part off to an ad hoc task that runs on cron. The observer stays fast, and the heavy lifting happens out of band.
Activating the plugin
Moodle caches the observer list, so dropping the files in place is not enough. The plugin has to be installed and the caches rebuilt:
- Copy the plugin into place at local/autocohort/.
- Run the upgrade so Moodle registers the new plugin and its observers. On the command line that is admin/cli/upgrade.php, or just visit the notifications page as an administrator.
- Purge caches with admin/cli/purge_caches.php, or through Site administration, Development, Purge caches.
Whenever you change db/events.php afterward, bump the version number in version.php and run the upgrade again. The observer cache only rebuilds when the plugin version changes, so a forgotten version bump is the usual reason a new observer never seems to fire.
Where this pattern fits
The same three-file shape covers a huge range of needs. Swap the event name and the handler body, and you can react to enrollments, completions, logins, grade changes, or anything else in the events list. Reach for it when you want behavior that fires off the back of a normal Moodle action, when the event carries the data you need, and when you would rather not touch core. Because everything lives in your own plugin, you get behavior core does not offer without giving up clean upgrades. That trade is almost always the right one.
Solin builds and maintains custom Moodle and Totara plugins, including event-driven integrations with external systems. If you would rather have this handled end to end, we are happy to help.
Moodle's AMD modules load JavaScript asynchronously and isolate dependencies using RequireJS. This guide covers module structure, injecting Moodle services, localization, and building production-ready minified output using Grunt.
SOP: Writing and Building AMD Modules in Moodle
This guide will walk you through creating, localizing, and building asynchronous module definition (AMD) JavaScript modules in Moodle, as well as how to pull in core Moodle services (like the notification system) from your AMD code.
1. What is AMD in Moodle?
Moodle uses RequireJS to load JavaScript modules asynchronously. An AMD module is a JavaScript file that:
- Declares its dependencies via a
define([...], function(...) { … }) call. - Exports one or more functions (often a single
init entry point). - Can be minified and concatenated into production-ready code.
By using AMD, modules only load when needed and avoid polluting the global namespace.
2. Folder Layout
Inside your plugin (e.g. mod/assessmentform/), you should have:
mod/assessmentform/
├── amd/
│ ├── src/ ← Your unbuilt source files
│ │ └── survey_init.js
│ └── build/ ← Moodle’s build step will place minified files here
│ └── survey_init.min.js
└── view.php ← Calls your AMD module via js_call_amd()
amd/src/Place your human-readable modules here.amd/build/After running the build, minified code will live here. Commit the build outputs so your plugin works without requiring Node on the production server.
3. Writing Your AMD Module
In amd/src/survey_init.js, you might have:
define([
'jquery',
'core/notification',
'core/str', // for translations
'survey-core',
'survey-js-ui'
], function($, notification, str, Survey) {
return {
init: function(args) {
// 1. Localization
var texts = args.str || {};
// str.get_string('key','mod_yourplugin') can also be used.
// 2. Load and render your SurveyJS
$.getJSON(args.jsonUrl).done(function(def) {
var survey = new Survey.Model(def);
survey.render(document.getElementById(args.containerId;
survey.onComplete.add(function(sender) {
// Build payload…
$.ajax({
url: args.postUrl,
method: 'POST',
data: JSON.stringify({ /* … */ }),
contentType: 'application/json',
dataType: 'json',
}).done(function(response) {
notification.addNotification({
message: texts.responsesavedsuccess || 'Saved successfully!',
type: 'success'
});
// redirect …
}).fail(function() {
notification.addNotification({
message: texts.responsefailed || 'Save failed.',
type: 'error'
});
});
});
}).fail(function() {
$('#'+args.containerId)
.html('<div class="alert alert-danger">Error loading form.</div>');
});
}
};
});
Key points
define([...], function(...) { … })List the module IDs you depend on. Moodle’s loader will map these to the right files.core/notificationExposes notification.addNotification({message, type}).core/strOffers str.get_string(key, component) for on-the-fly translations.js_call_amd() in PHPPass an associative array of arguments from PHP into your JS entry-point.
4. Injecting Moodle Services
Just list them in your dependency array:
define([
'jquery',
'core/notification',
'core/str',
'core/ajax',
// …your other libraries…
], function($, notification, str, Ajax) {
// You now have `notification`, `str` and `Ajax.call()` available.
});
core/ajaxUse Moodle’s AJAX wrapper:Ajax.call([{ methodname: 'mod_yourplugin_do_something', args: { foo: 1 }}])[0].then(function(result){ … }).catch(function(err){ notification.exception(err); });
5. Localization
- In your language file (
lang/en/mod_assessmentform.php): $string['responsesavedsuccess'] = 'Assessment saved successfully for {$a->fullname}.';$string['responsefailed'] = 'Error saving assessment.';- Pass them into JS when you call your AMD module:
$PAGE->requires->js_call_amd( 'mod_assessmentform/survey_init', 'init', [ 'str' => [ 'responsesavedsuccess' => get_string('responsesavedsuccess','mod_assessmentform'), 'responsefailed' => get_string('responsefailed','mod_assessmentform'), ], // …other args… ]);- In JS, access
args.str.responsesavedsuccess.
6. Building (Minifying) Your AMD
Moodle provides a Grunt setup out of the box. At your plugin root:
- Install dependencies (one-time, in the Moodle root). Moodle 4.5 requires Node 22.x:
npm install- Build all AMD modules (including yours), running grunt via npx so no global install is needed:
npx grunt amd- This reads every
amd/src/*.js in all plugins, minifies them, and writes to amd/build/*.min.js. - To build just your plugin, change into the plugin directory first, then run the same command. The Gruntfile detects the current directory and scopes the build to that component:
cd mod/assessmentform && npx grunt amd- Commit the contents of
amd/build/ into your plugin’s repository.
You do not need a Gruntfile in your plugin. Moodle has a single core Gruntfile at the Moodle root that handles every plugin; running it from inside your plugin directory (as above) scopes the build to your component, so there is no extra setup needed.
7. Summary Checklist
- Create
amd/src/yourmodule.js with a define([...], ...). - Load it in PHP with:
$PAGE->requires->js_call_amd('mod_yourplugin/yourmodule','init',[$args]);- Use injected services:
core/notification → notification.addNotification(...)core/str → str.get_string(...)core/ajax → Ajax.call(...)- Build with
npm install && grunt amd and commit amd/build/*.min.js. - Test on your dev site, then deploy.
With this workflow in place, you can rapidly iterate your JS in amd/src/ and then generate production-ready, minified AMD modules via Moodle’s standard Grunt toolchain.
Legacy Totara and Moodle checkouts requiring PHP 7.4 run into problems when the host's default PHP is newer. This guide shows how to create a dedicated PHP launcher directory and bypass Totara's PATH reset behavior so PHPUnit uses the correct interpreter.
Running the correct version of PHP and PHPUnit for a specific Moodle release is a prerequisite for reliable plugin tests. This guide covers setting up a parallel PHP 7.4 environment alongside a newer system PHP, and configuring PHPUnit to use it — working around the PATH reset issue in Totara's PHPUnit bootstrap.
Use this when a legacy Totara/Moodle checkout needs PHPUnit under PHP 7.4, but the host default php binary is a newer version and Totara's PHPUnit bootstrap keeps resolving nested php calls to the wrong interpreter.
Scope
This applies when all of the following are true:
- you are on a legacy release that actually supports PHP 7.4. PHP 7.4 applies only to Moodle 4.1 and earlier (3.9–4.1 era); Moodle 4.4+ requires PHP 8.1 or newer and will refuse to start on 7.4, so do not use this on a current Moodle
- the application requires PHP 7.4 for test tooling
- `/usr/bin/php` points to a newer version
- Totara's `test/phpunit/phpunit.php` bootstrap is used (the paths in this guide use Totara's
server/ code root; on stock Moodle the equivalents are admin/tool/phpunit/cli/util.php and admin/tool/phpunit/cli/init.php with no server/ prefix) - Composer dependency install is blocked by a newer Composer or audit defaults
Why the normal PATH trick fails
Totara's PHPUnit wrapper resets PATH using dirname(PHP_BINARY). If you start it with /usr/bin/php7.4, it prepends /usr/bin again, and nested php calls still resolve to /usr/bin/php instead of PHP 7.4.
Recommended approach
- Create a dedicated launcher directory with a copied `php` binary for PHP 7.4.
- Skip Totara's Composer bootstrap if necessary and install PHPUnit dependencies manually with a Composer version that still supports the locked package set.
- Use Totara's lower-level `server/admin/tool/phpunit/cli/util.php` entrypoint directly, not `test/phpunit/phpunit.php init`, if the wrapper still leaks to the system PHP.
Example
Assumptions:
- PHP 7.4 binary: `/usr/bin/php7.4`
- project root: `/path/to/project/totara`
Create the launcher:
mkdir -p /tmp/php74bin
cp /usr/bin/php7.4 /tmp/php74bin/php
/tmp/php74bin/php -r 'echo PHP_BINARY, PHP_EOL;'
Expected output:
/tmp/php74bin/php
If the Totara checkout expects totara/config.php, provide the instance bridge temporarily:
ln -sf /path/to/instance/config.php /path/to/project/totara/config.php
Prepare Composer manually when Totara's init step cannot:
cd /path/to/project/totara
curl -L https://getcomposer.org/download/2.6.6/composer.phar -o composer.phar
/tmp/php74bin/php composer.phar --version
/tmp/php74bin/php composer.phar -d test/phpunit update phpunit/phpunit brianium/paratest -W
Provide a PHPUnit config if the instance config does not define phpunit_* settings:
cat > /tmp/project-phpunit-config.php <<'PHP'
<?php
$CFG = new stdClass();
$CFG->dataroot = '/tmp/project_phpunit_data';
$CFG->dbtype = 'pgsql';
$CFG->dblibrary = 'native';
$CFG->dbhost = 'localhost';
$CFG->dbname = 'project_db';
$CFG->dbuser = 'dbuser';
$CFG->dbpass = 'dbpass';
$CFG->prefix = 'phpu_';
$CFG->dboptions = [
'dbpersist' => false,
'dbsocket' => false,
'dbport' => '',
];
PHP
ln -sf /tmp/project-phpunit-config.php /path/to/project/totara/test/phpunit/config.php
Initialize the isolated PHPUnit site directly:
cd /path/to/project/totara
/tmp/php74bin/php -d max_input_vars=5000 server/admin/tool/phpunit/cli/util.php --install
/tmp/php74bin/php -d max_input_vars=5000 server/admin/tool/phpunit/cli/util.php --buildconfig
Run a targeted test:
cd /path/to/project/totara
/tmp/php74bin/php -d max_input_vars=5000 server/admin/tool/phpunit/cli/util.php --run --filter local_multitenant_delete_capability_testcase server/local/multitenant/tests/delete_capability_test.php
Cleanup:
rm -f /path/to/project/totara/config.php
rm -f /path/to/project/totara/test/phpunit/config.php
Notes
- Prefer a project-local `composer.phar` instead of changing the system Composer.
- If Composer still blocks old locked packages, either use an older Composer release or repair the PHPUnit lockfile under PHP 7.4.
- Do not use a shell wrapper script for `php`; in this environment `PHP_BINARY` still resolved to the original binary path. A copied PHP 7.4 binary worked.
- The `test/phpunit/phpunit.php init` wrapper may still leak to the host default `php`. If that happens, use `server/admin/tool/phpunit/cli/util.php` directly.
Testing that Moodle plugins correctly post data to remote endpoints requires a local test server to capture requests. This guide shows how to set up a simple PHP endpoint, configure the plugin, and inspect the actual HTTP payloads being sent. It assumes a working local test environment; setting up a PHP 7.4 and PHPUnit toolchain for Moodle covers building one.
Testing Moodle Plugins That Transmit Data to a Remote Endpoint
Purpose
To verify that a Moodle plugin correctly sends data (e.g., grades) to a remote web service (typically via HTTP POST) when specific events are triggered, such as grading an assignment.
Prerequisites
- Moodle instance with the plugin installed and enabled
- Access to the Moodle server (for grading and logs)
- PHP CLI available (7.4+)
- A terminal: Bash/Zsh on Linux/macOS, or PowerShell/CMD on Windows
- Optional: curl or Postman for manual POSTs
Step 1: Set up a fake local endpoint
Linux / macOS
Create a test directory and a small index.php that prints whatever it receives:
mkdir -p ~/php/wstest
cd ~/php/wstest
<?php
// index.php
ob_start();
echo "=== REQUEST METHOD: " . $_SERVER['REQUEST_METHOD'] . " ===n";
foreach (getallheaders() as $name => $value) {
echo "$name: $valuen";
}
echo "n";
$input = file_get_contents('php://input');
echo "=== BODY ===n";
echo $input . "n";
$json = json_decode($input, true);
if ($json !== null) {
echo "n=== PARSED JSON ===n";
print_r($json);
}
file_put_contents('php://stdout', "=== LOG FROM index.php ===n" . ob_get_contents() . "n");
ob_end_flush();
Start the built-in PHP server and leave the terminal open; incoming requests are printed here:
php -S localhost:8000
Windows
- Download PHP from https://windows.php.net/ and extract it to
C:\php, then add C:\php to your PATH.
- Create the folder
C:\phpwstest and place the same index.php as above inside it.
Start the server from CMD or PowerShell:
cd C:\phpwstest
php -S localhost:8000
Step 2: Point Moodle to the local endpoint
- In the plugin settings, set the endpoint to your local test server:
http://localhost:8000
- Ensure the plugin’s event observer is registered in
db/events.php (your plugin may differ):
'callback' => 'local_yourpluginobserver::graded',
Confirm the observer method exists and is correctly namespaced:
public static function graded($eventdata) {
// your handling code
}
Note: if the plugin makes its outbound call through Moodle’s own curl class, Moodle’s cURL security may block requests to localhost by default. If nothing reaches your test server, see our guide on allowing Moodle cURL requests to localhost for development.
Step 3: Trigger the event
- Grade an assignment, or perform whatever action triggers the event.
- Watch the terminal running the test server.
Example terminal output:
=== LOG FROM index.php ===
=== REQUEST METHOD: POST ===
Content-Type: application/json
=== BODY ===
{"grade":"77.00000","gradetype":"point","activityid":"15030","userid":"145404"}
=== PARSED JSON ===
Array
(
[grade] => 77.00000
[gradetype] => point
[activityid] => 15030
[userid] => 145404
)
Step 4: Debug if nothing appears
- Confirm the plugin is enabled.
- Confirm the correct Moodle event is actually triggered by the action.
- Confirm the callback in
db/events.php matches your observer class and method.
- Add temporary debug logging, e.g.
error_log("Triggered observer::graded()");, and check the web server logs.
- Ensure the outbound request uses
CURLOPT_RETURNTRANSFER => true (or the equivalent on Moodle’s curl class) so the call completes.
- If you use Moodle’s
curl class, check whether cURL security is blocking localhost (see Step 2 note).
Success criteria and cleanup
- The endpoint receives a POST request with the expected keys and values.
- No errors are logged in Moodle or PHP.
- When done, press
Ctrl+C to stop the test server and revert the endpoint configuration.
Moodle's standard way to build AMD JavaScript is its grunt toolchain, which pulls in Node, Babel and a fair amount of configuration. If you just want to compile a plugin's amd/src/ files into the amd/build/*.min.js that Moodle serves, esbuild is a much lighter alternative. The key to doing it cleanly is to author named AMD modules so the output loads exactly like a grunt-built module. This guide covers that setup, configuration, and the build workflow.
Using esbuild to Build AMD Modules in a Moodle Plugin
Purpose
To compile JavaScript source files in amd/src/ into the AMD .min.js files in amd/build/ that Moodle loads, using esbuild instead of grunt.
Directory Structure
Your Moodle plugin should contain:
mod/yourplugin/
amd/
src/
editor.js
runtime.js
build/ (created automatically by esbuild)
Each source file must begin with a named AMD define() block. The module name has to be plugintype_pluginname/filename, matching the plugin's component name and the file's path under amd/src/. This is the crucial detail: baking the name in yourself is what lets Moodle load the module directly, without relying on any server-side fix-up (see the note at the end).
// amd/src/editor.js (plugin mod_yourplugin)
define('mod_yourplugin/editor', ['jquery'], function($) {
return {
init: function(cfg) {
console.log('Hello from editor.js', cfg);
}
};
});
Step 1: Initialize NPM (once)
From your plugin root:
npm init -y
This creates a package.json file.
Step 2: Install esbuild
npm install --save-dev esbuild
This installs esbuild locally into your plugin.
Step 3: Create build.js file
Create a file named build.js in the plugin root:
touch build.js
Paste in:
// build.js
const esbuild = require('esbuild');
esbuild.build({
entryPoints: ['amd/src/editor.js', 'amd/src/runtime.js'],
outdir: 'amd/build',
outExtension: { '.js': '.min.js' },
bundle: false, // keep each module separate; do not bundle dependencies
minify: true,
sourcemap: true, // emit .min.js.map, matching what the grunt build ships
target: ['es2015'],
}).then) => {
console.log('JS build complete.');
}).catcherr) => {
console.error('Build failed:', err);
process.exit(1);
});
Step 4: Run the build
From your plugin root:
node build.js
You should see:
JS build complete.
Then verify:
ls amd/build/
You should see:
editor.min.js
editor.min.js.map
runtime.min.js
runtime.min.js.map
Optional: Add NPM build script
Edit package.json to add:
"scripts": {
"build": "node build.js"
}
Now you can build with:
npm run build
TL;DR Summary
npm init -ynpm install --save-dev esbuild- Author each source file as a named module:
define('plugintype_name/file', [...], ...) - Create
build.js (minify + sourcemap, no format override) - Run
node build.js or npm run build - Moodle loads your AMD modules from
amd/build/*.min.js
Why the module name matters
Moodle's AMD loader expects every module to declare its own name (define('plugintype_name/file', ...)). The grunt toolchain injects that name automatically during its Babel step. esbuild does not, so you add it yourself in the source. If you omit the name and emit an anonymous define([...]), Moodle will still load it, but only because lib/requirejs.php runs a fallback (requirejs_fix_define()) that patches the name in at serve time. That fallback is explicitly marked in core as deprecated and slated for removal, so do not rely on it: name your modules and the output loads on its own merits.
A caveat on the plugins directory
If you submit a plugin to the moodle.org Plugins directory, its automated checks include a grunt build-state step that rebuilds amd/src/ and compares against the committed amd/build/. esbuild output will not byte-match grunt's, so a submitted plugin built this way may be flagged for mismatched build files. For in-house or client plugins this is a non-issue; for directory submissions, run the standard grunt build for the final committed artifacts.
You’re building a Moodle plugin that calls a service on your local machine — a REST API, a microservice, an AI backend, a webhook handler. You send the request through Moodle’s built-in cURL wrapper. Nothing comes back. No connection error, just silence, or a vague failure in your plugin’s response. For the integration itself, see Moodle web services and the REST API.
The cause: Moodle blocks outbound cURL requests to localhost (127.0.0.1, ::1) by default. This is intentional — it guards against Server-Side Request Forgery (SSRF), where an attacker tricks the server into fetching internal resources. On production that protection is essential. On a local dev machine it just gets in the way.
This guide shows how to lift that restriction.
What you’ll see
When a request is blocked, Moodle sets the curl error to “The URL is blocked.” and returns that string as the response body (not false or an empty response), and it triggers a \core\event\url_blocked event in the logs. If you only check the response body and not $curl->error, the failure can look like an empty or unexpected result, which is why this is easy to misdiagnose.
Fix: admin UI (recommended)
Go to Site administration > Security > HTTP security.
Step 1 — cURL blocked hosts list: clear this field entirely. When empty, Moodle skips the reserved-address check that blocks localhost.
Step 2 — cURL allowed ports list: confirm ports 80 and 443 are listed, then add the port your local service uses (e.g., 8000). If this list contains any entries at all, Moodle blocks every port not listed.
Step 3: click Save changes, then go to Site administration > Development > Purge all caches.
Fix: config.php (alternative)
If you can’t access the admin UI, or want a scripted setup, add these lines to config.php after the require_once(__DIR__ . '/lib/setup.php'); line:
$CFG->curlsecurityblockedhosts = ''; // empty = localhost not blocked
$CFG->curlsecurityallowedport = "80n443n8000"; // one port per line, NOT comma-separated
Moodle parses both of these settings by splitting on newlines, never commas, so each value must be on its own line. A comma-separated string is treated as a single invalid entry, which for the port allowlist means every port (including 80 and 443) gets blocked, the opposite of what you want. The simplest way to avoid the quoting pitfall is to set them in the UI on the HTTP security page (one host/port per line) rather than in config.php. Then purge caches.
Verify
Retry the cURL call from your plugin. If it still fails, enable developer debugging (Site administration > Development > Debugging, set to DEVELOPER) and check the output for messages from curl_security_helper.
Don’t carry this into production
An empty blocked hosts list disables SSRF protection for all local addresses. On a production server, the list should contain at minimum:
127.0.0.0/8
::1/128
10.0.0.0/8
172.16.0.0/12
192.168.0.0/16
Only remove entries if you have a specific, trusted internal service and understand the exposure.
Moodle's backup and restore API is powerful but complex. This guide covers the required context setup, database operations, and workflows for programmatic backups and targeted course/section restores, including critical pitfalls around controller cleanup.
This guide aims to help developers programmatically interact with Moodle's robust backup and restore (B&R) system. While powerful, the API can be complex, and certain scenarios, like targeted section restores, require precise handling.
This guide is based on the Moodle 4.1 backup & restore API.
I. General Principles & Best Practices
- Moodle Environment is Key:
- Configuration: Always include Moodle's main
config.php at the beginning of your script. - CLI Context: If the script is intended for command-line execution, define
define('CLI_SCRIPT', true); before including config.php. - Required Libraries: Include essential Moodle libraries. Common ones for B&R include:
$CFG->libdir . '/clilib.php' (for CLI scripts)$CFG->dirroot . '/backup/util/includes/backup_includes.php'$CFG->dirroot . '/backup/util/includes/restore_includes.php'$CFG->dirroot . '/course/lib.php' (for course/section operations)- User Context:
- Most B&R operations require a valid Moodle user context, typically an administrator, to ensure appropriate permissions.
Fetch an admin user using get_admin():global $USER; // Ensure $USER is global if not already
$USER = get_admin();
- Error Handling & Logging:
- Exception Handling: Moodle's B&R API can throw various exceptions (e.g.,
moodle_exception, backup_exception, restore_controller_exception). Wrap B&R operations in try...catch blocks to handle potential errors gracefully. - Detailed Logging: Implement a robust logging mechanism to track the script's progress, key variable states, and any errors encountered. This is invaluable for debugging complex B&R workflows. For CLI scripts,
cli_writeln() can be used for console output, supplemented by logging to a file. - Resource Management – The
destroy() Method: - CRITICAL PITFALL:
backup_controller and restore_controller objects, along with their associated backup_plan and restore_plan objects, create complex internal structures. They may hold database connections, temporary file references, and significant amounts of data in memory. - Best Practice: ALWAYS call the
$controller->destroy() method after you are completely finished with the controller object. This is typically done in a finally block to ensure cleanup even if errors occur. The destroy() method on the controller will also handle the destruction of its associated plan. - Consequences of Neglect: Failure to call
destroy() can lead to memory leaks, PHP timeouts, database connection exhaustion, and persistent temporary data (e.g., "existing temptables found" errors when Moodle attempts its own cleanup later). - Temporary Directories:
- Moodle's Temp Space: Moodle B&R operations use a temporary directory, typically located at
$CFG->dataroot . '/temp/backup/'. - Path Relativity (Restore): When providing a path to extracted backup contents for a
restore_controller, this path must be relative to Moodle's main backup temporary directory (i.e., $CFG->dataroot/temp/backup/). - Creating Temp Dirs: Use Moodle's
make_backup_temp_directory() function to create uniquely named subdirectories within this temporary space. This function returns an absolute path. - Cleanup:
- Moodle's controller
destroy() methods are responsible for cleaning up Moodle's internal temporary files and database records related to that specific B&R operation. - Your script is responsible for cleaning up any additional temporary directories or files it creates (e.g., a directory where you manually copy an MBZ file or extract its contents before passing a relative path to the
restore_controller). Use fulldelete() for this. - Database Context (If Working with Multiple Databases):
- If your script interacts with a separate database for sourcing backup data (e.g., a dedicated backup Moodle instance's database), ensure you manage the global
$DB object context correctly.
Switch to the backup database connection before performing operations against it, and restore the original live Moodle database connection immediately afterward.global $DB;
$original_live_db_connection = $DB;
// Assume $backup_db_conn is a Moodle database object for your backup DB
$DB = $backup_db_conn;
// ... perform operations against the backup database ...
$DB = $original_live_db_connection; // Switch back to live Moodle DB
- Moodle Version Awareness:
- The B&R API, like other Moodle APIs, can evolve between Moodle versions. Code written for one version might require adjustments for another. Always test thoroughly against your target Moodle version.
- Consult the Source Code:
- When API behavior is unclear or documentation is sparse, the Moodle source code (primarily in
backup/controller/, backup/moodle2/, and related task/plan files) is the definitive reference. PHPDoc comments within the code can also be very helpful. - Iterative Development & Debugging:
- Programmatic B&R can be complex. Start with simpler operations and incrementally add complexity. Use systematic debugging techniques and the logging you've implemented.
II. Key Components of the B&R API
- Controllers (
backup_controller, restore_controller): - These are the central orchestrators for backup and restore operations.
- Instantiation Examples:
new backup_controller($type, $id, $format, $interactive, $mode, $userid)new restore_controller($tempdir_relative_path, $courseid, $interactive, $mode, $userid, $target)- Common Parameters:
$type (Backup): The scope of the backup, e.g., backup::TYPE_1COURSE, backup::TYPE_1SECTION.$id (Backup): The Moodle ID of the item to be backed up (e.g., course ID, section ID from the source database).$format: The backup format, typically backup::FORMAT_MOODLE.$interactive: For scripts, this should be backup::INTERACTIVE_NO.$mode: The purpose/context of the operation, e.g., backup::MODE_GENERAL.$userid: The Moodle user ID of the user performing the operation.$tempdir_relative_path (Restore): The path to the directory containing the extracted backup contents, relative to $CFG->dataroot/temp/backup/.$courseid (Restore): The Moodle ID of the target course for the restore operation.$target (Restore): Specifies how the restore interacts with the target course, e.g., backup::TARGET_NEW_COURSE, backup::TARGET_CURRENT_ADDING.- Typical Lifecycle: Instantiate Controller -> Get Plan -> (Optionally Adjust Plan Settings/Tasks) -> (For Restore: Execute Prechecks) -> Execute Plan -> (Optionally Get Results) -> Destroy Controller.
- Plans (
backup_plan, restore_plan): - The detailed blueprint of the B&R operation, generated by its controller.
- Contains:
- Settings: High-level configuration options for the entire operation (e.g., whether to include users, logs, files). Accessed via
$plan->get_setting('setting_name'). - Tasks: An ordered list of individual operations that will be performed (e.g., restore course structure, restore a specific section's content, restore an activity module). Accessed via
$plan->get_tasks(). - The plan is obtained from the controller using
$controller->get_plan(). - Tasks (e.g.,
backup_section_task, restore_section_task, restore_activity_task): - Represent discrete units of work within a plan. Each task is responsible for a specific part of the B&R process.
- Tasks can have their own settings, specific to that unit of work (e.g., whether to include user data for a particular activity).
- Importance for Targeted Operations: For specific operations like restoring a single section into a particular place, understanding and correctly configuring the relevant task (e.g.,
restore_section_task) is crucial. - Settings (
backup_setting): - Objects that represent configurable options within the B&R process.
- They exist at both the overall plan level and within individual tasks.
- Access settings using
$plan->get_setting('name') or $task->get_setting('name'). - Set their values using
$setting_object->set_value(...). - Challenge: Discovering the exact name of a setting and determining whether it's a plan-level or task-level setting often requires inspecting Moodle source code (e.g., the
define_settings() methods in task classes or how settings are added in plan builder classes). - Backup Types & Restore Targets (Constants):
- These constants, defined in the
backup class in backup/backup.class.php (loaded via backup_includes.php), dictate the scope and destination of B&R operations. - Backup Types (Scope Examples):
backup::TYPE_1COURSE: Backs up an entire course.backup::TYPE_1SECTION: Backs up a single section from a course.backup::TYPE_1ACTIVITY: Backs up a single activity module.- Restore Targets (Destination Examples):
backup::TARGET_NEW_COURSE: Restores the backup into a brand new Moodle course.backup::TARGET_CURRENT_ADDING: Adds content from the backup into the course specified by $courseid (passed to restore_controller), merging with any existing content.backup::TARGET_CURRENT_DELETING: Deletes existing content from the target $courseid before restoring content from the backup.- (Others like
TARGET_EXISTING_ADDING, TARGET_EXISTING_DELETING are for restoring into a different existing course, selected during UI workflows or configured programmatically). - Key Interaction: The interplay between the backup
TYPE_... and the restore TARGET_... significantly influences how settings and tasks behave. For instance, restoring a TYPE_1SECTION backup using TARGET_CURRENT_ADDING requires careful targeting of the restore_section_task to place the content correctly.
III. Core Operations: Step-by-Step with Examples
A. Programmatically Backing Up a Single Section
This example demonstrates backing up a specific section from a Moodle course.
// Assumed variables:
// $source_db_connection: Moodle DB connection object for the source database (if different from live).
// $USER: An admin user object.
// $source_course_id: The ID of the course containing the section.
// $source_section_id: The ID of the section record to back up (from the source DB).
global $DB; // Live Moodle DB
$original_live_db = $DB;
$copied_mbz_path = null;
$backup_controller = null;
$script_controlled_mbz_temp_dir = null;
try {
// Switch DB context if source is different
if (isset($source_db_connection) && $source_db_connection !== $original_live_db) {
$DB = $source_db_connection;
cli_writeln("Switched DB context to source for backup.");
}
cli_writeln("Backing up section ID {$source_section_id} from course {$source_course_id}");
$backup_controller = new backup_controller(
backup::TYPE_1SECTION,
$source_section_id,
backup::FORMAT_MOODLE,
backup::INTERACTIVE_NO,
backup::MODE_GENERAL,
$USER->id
);
$backup_plan = $backup_controller->get_plan();
// Configure general plan settings (e.g., exclude user data)
$general_plan_settings = $backup_plan->get_settings();
if (isset($general_plan_settings['users']) && $general_plan_settings['users'] instanceof backup_setting) {
$general_plan_settings['users']->set_value(0);
}
// Add other general settings as needed (e.g., role_assignments, grade_histories to 0)
// Configure settings for the specific section task (e.g., ensure activities and files are included)
$backup_tasks = $backup_plan->get_tasks();
foreach ($backup_tasks as $task) {
if ($task instanceof backup_section_task && $task->get_sectionid() == $source_section_id) {
// Ensure activities and files within this section are backed up
if (method_exists($task, 'get_setting' { // Good practice to check
$activities_setting = $task->get_setting('activities');
if ($activities_setting instanceof backup_setting) {
$activities_setting->set_value(1); // 1 for include
}
$files_setting = $task->get_setting('files');
if ($files_setting instanceof backup_setting) {
$files_setting->set_value(1); // 1 for include
}
}
break; // Found the relevant section task
}
}
$backup_controller->execute_plan();
$backup_status = $backup_controller->get_status();
if ($backup_status != backup::STATUS_FINISHED_OK && $backup_status != backup::STATUS_FINISHED_WARN) {
throw new moodle_exception("Backup failed. Controller status: " . $backup_status);
}
$backup_results = $backup_controller->get_results();
$moodle_managed_backup_file = $backup_results['backup_destination'] ?? null;
if (!$moodle_managed_backup_file instanceof stored_file) {
throw new moodle_exception('Backup destination file (stored_file object) not found in backup results.');
}
// Copy the Moodle-managed backup file to a script-controlled temporary location
$script_controlled_mbz_temp_dir = make_backup_temp_directory('my_script_section_backup_' . $source_section_id . '_' . uniqid(;
$copied_mbz_path = $script_controlled_mbz_temp_dir . DIRECTORY_SEPARATOR . $moodle_managed_backup_file->get_filename();
if (!$moodle_managed_backup_file->copy_content_to($copied_mbz_path {
throw new moodle_exception('Failed to copy MBZ file to script-controlled location: ' . $copied_mbz_path);
}
cli_writeln("Section backup MBZ successfully created at: " . $copied_mbz_path);
} catch (Exception $e) {
cli_writeln("ERROR during backup operation: " . $e->getMessage(;
if ($e instanceof moodle_exception && !empty($e->debuginfo {
cli_writeln("Moodle Exception Debug Info: " . $e->debuginfo);
}
// Further error handling or re-throwing as appropriate
} finally {
// Restore original DB context if it was changed
if (isset($source_db_connection) && $source_db_connection !== $original_live_db && $DB !== $original_live_db) {
$DB = $original_live_db;
cli_writeln("Restored DB context to live Moodle DB.");
}
// Destroy the backup controller to clean up Moodle's internal temp files and DB records
if ($backup_controller instanceof backup_controller) {
try {
$backup_controller->destroy();
} catch (Exception $e_destroy) {
cli_writeln("Warning: Exception during backup_controller destroy: " . $e_destroy->getMessage(;
}
}
// The $copied_mbz_path is now available for the restore step.
// Cleanup of $script_controlled_mbz_temp_dir should happen after $copied_mbz_path is no longer needed.
}
// The $copied_mbz_path variable now holds the path to the generated .mbz file.
// Remember to clean up $script_controlled_mbz_temp_dir later using fulldelete().
B. Programmatically Restoring a Single Section into a Specific Location in an Existing Course
This scenario requires creating an empty placeholder section in the target course first, then instructing the restore process to populate this specific placeholder.
// Assumed variables:
// $target_live_course_id: ID of the live Moodle course to restore into.
// $target_insertion_section_number: The desired section *number* for the new content.
// $USER: An admin user object.
// $path_to_section_mbz: Absolute path to the .mbz file created in the backup step.
// $original_section_backup_details: (Optional) An object/array holding metadata (name, summary, visibility)
// from the original section in the backup, to apply after restore.
global $DB; // Live Moodle DB
$restore_controller = null;
$script_controlled_extraction_dir = null; // For script-managed extraction path (absolute)
$placeholder_section_id_in_live_course = null;
try {
// 1. Create a placeholder section in the live target course
cli_writeln("Creating placeholder section at position {$target_insertion_section_number} in course {$target_live_course_id}...");
$placeholder_section_record = course_create_section($target_live_course_id, $target_insertion_section_number);
if (!$placeholder_section_record || !isset($placeholder_section_record->id {
throw new moodle_exception("Failed to create placeholder section in target course.");
}
$placeholder_section_id_in_live_course = $placeholder_section_record->id;
cli_writeln("Placeholder section created in live course: ID {$placeholder_section_id_in_live_course}, Number {$placeholder_section_record->section}");
// 2. Extract the .mbz file to a Moodle temporary directory (for restore_controller)
// The path passed to restore_controller must be relative to $CFG->dataroot/temp/backup/
$extraction_dir_relative_name = 'my_script_restore_extraction_' . uniqid();
$script_controlled_extraction_dir = make_backup_temp_directory($extraction_dir_relative_name); // Gets absolute path
if (!$script_controlled_extraction_dir) {
throw new moodle_exception("Could not create script-controlled extraction directory.");
}
$packer = get_file_packer('application/vnd.moodle.backup'); // Standard Moodle MBZ packer
if (!$packer) {
throw new moodle_exception('Could not get file packer for MBZ.');
}
if (!$packer->extract_to_pathname($path_to_section_mbz, $script_controlled_extraction_dir {
$packer_errors = $packer->get_errors();
throw new moodle_exception('Failed to extract .mbz file: ' . implode('; ', $packer_errors;
}
cli_writeln("MBZ successfully extracted to: " . $script_controlled_extraction_dir . " (Relative path for controller: " . $extraction_dir_relative_name . ")");
// Ensure moodle_backup.xml exists in the extraction
if (!file_exists($script_controlled_extraction_dir . DIRECTORY_SEPARATOR . 'moodle_backup.xml' {
throw new moodle_exception('moodle_backup.xml not found in extraction directory.');
}
// 3. Instantiate the restore_controller
$restore_controller = new restore_controller(
$extraction_dir_relative_name, // Relative path to extracted content
$target_live_course_id, // Target course ID
backup::INTERACTIVE_NO,
backup::MODE_GENERAL,
$USER->id,
backup::TARGET_CURRENT_ADDING // Adding content to the current (target) course
);
// 4. (Optional but recommended) Execute prechecks
if ($restore_controller->get_status() == backup::STATUS_NEED_PRECHECK) {
cli_writeln("Executing restore prechecks...");
if (!$restore_controller->execute_precheck( {
// Handle precheck failures/warnings from $restore_controller->get_precheck_results()
// For example, log them and decide whether to proceed.
$precheck_results = $restore_controller->get_precheck_results();
cli_writeln("Restore prechecks indicated issues: " . var_export($precheck_results, true;
// Depending on severity, you might throw an exception here.
}
cli_writeln("Restore prechecks completed. Status: " . $restore_controller->get_status(;
}
// 5. Get the restore plan
$restore_plan = $restore_controller->get_plan();
if (!$restore_plan) {
throw new moodle_exception("Failed to retrieve restore plan from controller.");
}
// 6. CRITICAL: Configure the restore_section_task to target the placeholder
// This is essential for TYPE_1SECTION restores into a specific slot.
if ($restore_controller->get_type() == backup::TYPE_1SECTION) {
$restore_tasks = $restore_plan->get_tasks();
$section_task_configured_for_target = false;
foreach ($restore_tasks as $task) {
if ($task instanceof restore_section_task) {
// This task will process the single section from the backup.
// We instruct it to use our placeholder section's ID as its target in the live course.
cli_writeln("Configuring restore_section_task (Task Name: {$task->get_name()}) to target live section ID: {$placeholder_section_id_in_live_course}");
$task->set_sectionid($placeholder_section_id_in_live_course);
$section_task_configured_for_target = true;
break; // Assuming only one restore_section_task for a TYPE_1SECTION backup
}
}
if (!$section_task_configured_for_target) {
throw new moodle_exception("Could not find or configure the restore_section_task within the plan for TYPE_1SECTION restore.");
}
} else {
// This script's specific targeting logic is primarily for TYPE_1SECTION.
// Handling other backup types (e.g., TYPE_1COURSE) would require different plan/task configuration.
cli_writeln("WARNING: The backup type is not TYPE_1SECTION (actual: {$restore_controller->get_type()}). The specific section targeting logic used here may not apply or work as expected.");
}
// 7. Adjust other general plan settings (e.g., exclude user data, roles)
$general_restore_plan_settings = $restore_plan->get_settings();
if (is_array($general_restore_plan_settings {
foreach ($general_restore_plan_settings as $setting) {
if ($setting instanceof backup_setting) {
if (in_array($setting->get_name(), ['users', 'role_assignments', 'grade_histories', 'course_module_completion'] {
$setting->set_value(0); // 0 for exclude
}
}
}
}
// 8. Execute the restore plan
if ($restore_controller->get_status() != backup::STATUS_AWAITING) {
throw new moodle_exception("Restore controller is not in AWAITING state before execute_plan. Current status: " . $restore_controller->get_status(;
}
cli_writeln("Executing restore plan...");
$restore_controller->execute_plan();
$restore_status = $restore_controller->get_status();
if ($restore_status != backup::STATUS_FINISHED_OK && $restore_status != backup::STATUS_FINISHED_WARN) {
throw new moodle_exception("Restore plan execution failed. Controller status: " . $restore_status);
}
cli_writeln("Restore plan execution finished with status: " . $restore_status);
// 9. (Optional but recommended) Update metadata of the now-populated section
// The restore_section_structure_step populates the section. This step ensures
// metadata like name, summary, visibility exactly matches the source backup object.
$final_live_section_obj = $DB->get_record('course_sections', ['id' => $placeholder_section_id_in_live_course]);
if ($final_live_section_obj && isset($original_section_backup_details {
$update_params = new stdClass();
$update_params->id = $final_live_section_obj->id;
$update_params->name = $original_section_backup_details->name;
$update_params->summary = $original_section_backup_details->summary;
$update_params->summaryformat = $original_section_backup_details->summaryformat;
$update_params->visible = $original_section_backup_details->visible;
$update_params->availability = $original_section_backup_details->availability; // If applicable
$DB->update_record('course_sections', $update_params);
cli_writeln("Metadata (name, summary, etc.) updated for restored section ID {$final_live_section_obj->id}.");
} else if (!$final_live_section_obj) {
cli_writeln("WARNING: Could not re-fetch section ID {$placeholder_section_id_in_live_course} after restore to update metadata. It might not have been correctly populated.");
}
cli_writeln("Section content successfully restored into live section ID {$placeholder_section_id_in_live_course}.");
// 10. Rebuild the course cache for the target course
cli_writeln("Rebuilding course cache for course ID {$target_live_course_id}...");
rebuild_course_cache($target_live_course_id, true);
cli_writeln("Course cache rebuilt.");
} catch (Exception $e) {
cli_writeln("ERROR during restore operation: " . $e->getMessage(;
if ($e instanceof moodle_exception && !empty($e->debuginfo {
cli_writeln("Moodle Exception Debug Info: " . $e->debuginfo);
}
// Further error handling
} finally {
// Destroy the restore controller
if ($restore_controller instanceof restore_controller) {
try {
$restore_controller->destroy();
} catch (Exception $e_destroy) {
cli_writeln("Warning: Exception during restore_controller destroy: " . $e_destroy->getMessage(;
}
}
// Clean up the script-controlled extraction directory
if ($script_controlled_extraction_dir && is_dir($script_controlled_extraction_dir {
fulldelete($script_controlled_extraction_dir);
cli_writeln("Cleaned up script-controlled extraction directory: " . $script_controlled_extraction_dir);
}
// The original .mbz file ($path_to_section_mbz) might also need cleanup if it was temporary.
}
IV. Stumbling Blocks & Pitfalls (Summary)
- Targeting
TYPE_1SECTION Restores into Existing Courses: - The Challenge: Ensuring the single section's content from the backup is placed into a specific, pre-determined slot (placeholder section) within the target course.
- Solution: Create an empty placeholder section using
course_create_section(). Then, during the restore process, find the restore_section_task in the restore_plan and call its set_sectionid() method, passing the ID of your placeholder section. This directs the task to populate that specific section record. - Distinguishing Plan-Level vs. Task-Level Settings:
- Settings can apply globally to the plan or be specific to individual tasks. Knowing where a setting resides is crucial for configuring it correctly.
- Identifying Correct Setting Names:
- Setting names are not always obvious. Inspecting
define_settings() methods in task classes or related Moodle source code is often necessary. - Essential
destroy() Calls: - Always call
$controller->destroy() to prevent resource leaks and cleanup issues. - Correct Paths for
restore_controller: - The
$tempdir parameter for new restore_controller(...) must be the path to the directory containing the extracted backup contents, relative to $CFG->dataroot/temp/backup/. It is not the path to the .mbz file itself.
V. Debugging Tips
- Comprehensive Logging: Log controller statuses, plan details (settings and tasks), key variable values, and any error messages.
- Inspect Objects: Use
var_dump() or print_r() on controller, plan, task, and setting objects to understand their structure and current state (e.g., $plan->get_settings(), $task->get_settings()). - Moodle Developer Debugging: Enable developer-level debugging in Moodle's site administration to get more verbose error messages and stack traces from Moodle itself.
- Server and Moodle Error Logs: Check the web server's error log and Moodle's configured error log file for PHP errors or other Moodle-specific error details.
- Incremental Testing: Start with the simplest possible B&R operation and gradually add complexity to isolate where issues arise.
backup/backup.class.php: Refer to the backup class in this file (loaded via backup_includes.php) for the definitions of all backup:: constants.- Examine
moodle_backup.xml: This file, found within an extracted MBZ, describes the structure and contents of the backup. Reviewing it can provide insights into relevant settings and tasks.
PsySH provides an interactive REPL for Moodle development, similar to `rails console`. This guide shows how to set it up project-locally with a custom init script, giving you direct access to Moodle’s database and API without polluting the repository.
This is a guide for Moodle developers who want to install and use PsySH on a per-project basis, using an init.php file to load Moodle and helper logic — without polluting the Moodle Git repo.
Installing and Using PsySH REPL for Moodle Development (Project-Scoped)
Overview
This guide helps Moodle developers:
- Set up an interactive REPL (like
rails console or irb) using PsySH
- Run it within a Moodle context (with
$CFG, $DB, $USER, etc.)
- Keep all files outside the Git-tracked Moodle directory
- Ensure correct PHP version is used per project
1. Requirements
- PHP CLI (matching your project version, e.g.
php7.4)
- Composer (any global version)
- Local Moodle install (e.g. in
~/php/icm/public_html/)
2. Install PsySH per project
Create a tools/ directory next to your Moodle root:
cd ~/php/icm
mkdir tools && cd tools
php7.4 /usr/bin/composer require psy/psysh:^0.11
This installs a project-local version of PsySH compatible with PHP 7.4. (Assumption: your webroot is something like ~/php/icm/public_html)
3. Create init.php to bootstrap Moodle and helpers
Inside ~/php/icm/tools/init.php, add:
<?php
// Load Moodle
define('CLI_SCRIPT', true);
require(__DIR__ . '/../public_html/config.php');
// Simulate a logged-in admin user
$USER = get_admin();
// Setup typical Moodle page context
$PAGE = new moodle_page();
$PAGE->set_context(context_system::instance(;
$PAGE->set_url('/');
$PAGE->set_pagelayout('admin');
$COURSE = get_site();
$SITE = $COURSE;
// Optional helpers
function uname(int $id): string {
global $DB;
$user = $DB->get_record('user', ['id' => $id], '*', MUST_EXIST);
return fullname($user);
}
echo "[Moodle REPL ready — user={$USER->username}]
";
This gives you access to the full Moodle environment in the REPL.
4. Create a launch script: moodlerepl
Inside ~/php/icm/tools/, create:
touch moodlerepl
chmod +x moodlerepl
With the contents:
#!/usr/bin/env bash
DIR="$(cd "$(dirname "$0")" && pwd)"
PHPBIN="php7.4" # Change if needed
"$PHPBIN" "$DIR/vendor/bin/psysh" "$DIR/init.php"
Now you can just run:
./moodlerepl
5. Example REPL session
$ ./moodlerepl
[Moodle REPL ready — user=admin]
Psy Shell v0.11.22 (PHP 7.4.33 — cli)
>>> $DB->get_record('user', ['id' => 2]);
>>> uname(2);
6. Keep everything out of Git
Add this to your .gitignore if needed:
tools/
Or selectively ignore:
tools/vendor/
tools/init.php
tools/moodlerepl