Designing a Local Enrolment-Key Plugin for Moodle: Boundaries, Checks, and Failure Modes
An architecture note on building a Moodle enrolment-key flow as a local plugin: minimal design, trust boundaries for user-supplied keys, transactional enrolment, failure modes, and upgrade triggers.
10 Oct 2025, 20:24 UTC

The problem
You need self-enrolment into Moodle courses gated by a shared key, but the built-in self-enrolment method doesn't fit — for example, keys must be single-use, issued per cohort, or validated against an external list. The practical takeaway: the smallest sound design is a local plugin that reuses Moodle's core enrolment API for the actual enrolment and keeps only key generation, storage, and comparison in your own code. Everything else — capability checks, transactions, audit logging — already exists in core and should not be reimplemented.
Assumptions below target Moodle 4.x. Hook signatures in the enrol plugin API changed across the 4.x line, so verify against your exact minor version before writing code.
Requirements
- A user with a valid key can enrol into a specific course without manual intervention.
- Keys are never stored or compared in plaintext; only a hash is persisted.
- Invalid, revoked, or expired keys produce a generic error — never a database message or a hint about which part failed.
- Enrolment remains subject to normal gates: course visibility, enrolment instance status, and any capacity limit on the instance.
- Every attempt, success or failure, is auditable.
The smallest suitable design
Resist building a full enrol plugin (an enrol_ component) unless you need it to appear in the course enrolment-methods UI. A local plugin with a small form and a call into the core API is less code and less upgrade surface:
- Key storage: a plugin table holding
courseid,keyhash(a salted hash, not the raw key),timerevoked/timeexpired, and a usage counter if keys are limited-use. - Key validation: a routine that sanitizes input, enforces a maximum length (keys are user-supplied strings; unbounded input can strain your schema and logs), hashes, and compares against stored hashes in constant time where practical.
- Enrolment hand-off: on a valid key, call the core enrolment API (
enrol_try_internal_enrol()or the self-enrol instance's enrol method) rather than inserting intouser_enrolmentsyourself. Core handles role assignment, enrolment records, events, and gradebook setup.
Trust and data boundaries
The key string crosses a trust boundary: it arrives from an untrusted client. Treat it accordingly:
- Validate and cap length before it touches the database. Use Moodle's DML layer, which parameterizes queries — but only if you actually go through
$DBmethods. Hand-written SQL with concatenated input bypasses that protection; don't do it. - Never return different error messages for "key doesn't exist" vs "key revoked" vs "key expired" if key guessing matters in your context; a single generic failure message plus a server-side audit log entry is safer.
- The enrolment call itself runs with the user's session. Check capabilities (
moodle/course:enrolis not required for self-enrolment, but confirm the enrolment instance is enabled and the course is visible) before attempting enrolment, and let core re-check them.
Operational checks
Before enrolment proceeds, verify in order:
- Course exists and is visible to the user (or the key flow deliberately bypasses visibility — decide explicitly).
- The key maps to an active, unrevoked, unexpired record for that course.
- The user is not already enrolled (core returns a standardized result for this; surface it as "already enrolled," not an error).
- Capacity limits, if any, are not exceeded.
Wrap the key-check-and-enrol sequence in a database transaction. Without it, two concurrent requests presenting the same single-use key can both pass validation before either enrolment commits — a classic race. Moodle's DML supports delegated transactions ($DB->start_delegated_transaction()); use them around the validation-and-consume step.
Failure modes and responses
| Failure | Response | Audit |
|---|---|---|
| Invalid or missing key | Generic error, no DB detail | Log attempt with course id and user id |
| Revoked/expired key | Generic error (or specific, per your threat model) | Log with key record id |
| Concurrent use of a limited key | Second transaction fails cleanly; user sees "key already used" | Log both attempts |
| DB constraint violation during enrol | Roll back transaction; standardized error code | Log exception via Moodle's debugging/exception handling |
Verifying the implementation
- Read your instance's enrol source (e.g., the self-enrolment plugin's
lib.phpand any local key-handling code) to confirm how hashes are generated and compared in your version — do not assume a particular hashing scheme. - Run Moodle's enrolment-related PHPUnit tests plus your own invalid-key cases, and confirm failed attempts leave no rows in
user_enrolments. - Simulate concurrent submissions of the same key (even two scripted curl requests in a loop) and confirm only one enrolment succeeds.
- After any Moodle point upgrade, check the upgrade log and release notes for enrol API deprecations affecting your plugin's hooks.
Conditions that would change the design
- Per-user or high-volume keys: if keys are issued individually at scale, move to a full enrol plugin so keys integrate with course enrolment management UI and reporting.
- External issuance: if keys come from an external system, add an HMAC-signed token or webservice validation step rather than storing hashes locally.
- Moodle upgrades: enrol plugin hook signatures have changed across major versions; if core migrates key-related storage or hooks, plan a data migration in your plugin's
upgrade.php. - Brute-force concern: if keys are short, add rate limiting per user/IP at the form handler; long random keys may not need it.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.