Skip to content
Rhodie

Regulated Course Registration: Dog Business School


2026Moodle, PHP, Javascript, Cloudflare Workers, Cloudflare R2, Cloudflare Access

About This Project

Dog Business School is the UK's leading provider of Ofqual-regulated dog care qualifications. The full story of my work with them has its own entry in this portfolio. This piece is about one specific project within it: the system that handles learner registration on the regulated qualification courses.

Dog Business School runs two kinds of course under one roof. There are the Ofqual-regulated qualifications, and there are in-house designed courses covering licensing guidance and business skills. The regulated qualifications carry formal obligations that the in-house courses do not. Before a learner can start one, the awarding organisation requires a completed Course Registration & Learner Declaration: personal details, equality and diversity information, educational background, reasonable adjustments, a set of declarations, a signature, and photo ID to confirm the learner is who they say they are.

The business has grown considerably over the past couple of years, and this project came from wanting the registration process to scale with it. The goals were straightforward: strengthen the security around learner data, bring the whole workflow into one central place, get students registered and into their courses faster, and make it clear at a glance what is waiting and who is dealing with it.

The most interesting requirement was the photo ID. Identity documents are about as sensitive as learner data gets, and the aim was for their handling to be something the business could point at with complete confidence. So the design was held to a deliberately hard standard. ID documents must never touch the Moodle server, not even in passing. Nobody on Moodle, including site administrators, should be able to read a stored ID. And documents should delete themselves automatically after a short window. Not because anyone is distrusted, but because "nobody can" is a much stronger statement than "nobody would". Most of the engineering in this project exists to make those three sentences true.

Conditional by Design

Moodle has no native concept of this kind of registration step, and certainly not one that applies to some courses and not others. Anything bolted on at site level tends to be all or nothing. With two very different catalogues on the same platform, all or nothing was the wrong shape. A learner buying an in-house licensing course should not be asked for qualification paperwork their course does not involve, while a learner on a regulated qualification must complete registration before starting, because Ofqual compliance depends on it.

So the whole system is conditional per course. An administrator flags a course as regulated and records the qualification title, level, and awarding organisation against it. From that moment the course is gated: learners are redirected to the registration form until they have submitted it and been approved. Every other course is completely untouched. Buy an in-house course and you are learning within minutes. Enrol on a regulated qualification and registration comes first, exactly as compliance requires.

There is one more control on that page. When a course is added to the regulated list, you choose whether the requirement applies to all enrolments or only to learners who enrol from that point onwards. The second option is the default, and the first exists for when an awarding organisation requires it. It is a small setting, but it means bringing a course into the system is always a deliberate, controlled change.

The Shape of the Build

What shipped is two custom Moodle plugins and a pair of Cloudflare Workers.

The main plugin, local_dbsregulated, provides the gate, the registration form itself (including a drawn signature pad and the ID upload), a review queue where the team approves or rejects each submission, a notification pipeline so a new form is never missed, and a PDF export that produces the registration pack for the awarding organisation. The second plugin, local_dbscompletion, tracks completion deadlines and gets its own section further down. The two Workers handle ID storage, and they are the heart of the whole thing.

The gate hooks into Moodle at the right level rather than the convenient one. Moodle calls a plugin callback after every require_login() involving a course, which means the check runs on every page of a course, not just its front door:

if (!isloggedin() || isguestuser()) {
    if ($cannotredirect) {
        throw new moodle_exception('registrationrequired', 'local_dbsregulated', $formurl);
    }
    redirect(get_login_url());
}

if (!\local_dbsregulated\manager::is_blocked($USER->id, $courseid)) {
    return;
}

if ($cannotredirect) {
    throw new moodle_exception('registrationrequired', 'local_dbsregulated', $formurl);
}

redirect($formurl);

Requests that cannot redirect, such as AJAX calls and web service requests, get an exception instead, which is what stops the Moodle mobile app from wandering past the gate. Anonymous visitors fail closed: even if guest access were accidentally switched on for a regulated course, a logged-out visitor lands on the login page, never the course content. Teachers, managers, and admins are exempt. The gate applies to learners, and only learners.

A Closer Look: Where the Photo IDs Live

The obvious way to handle an ID upload in Moodle is a file picker. The document lands in Moodle's file storage, the review page shows it inline, done in an afternoon. It also fails the standard completely: the file passes through the server, sits in its storage indefinitely, and anyone with admin rights can open it.

My first real design used Cloudflare R2 (object storage) with presigned URLs, so the browser uploads directly to the bucket using a URL signed inside Moodle. Better, because the file skips the server, but I scrapped it before it ever reached production. It has a flaw I could not live with. The signing credential has to sit in Moodle's settings, R2 has no such thing as a write-only API token, and so anyone who could read that setting could mint themselves a download URL. The guarantee I actually wanted, that Moodle cannot read IDs back, was not a guarantee at all. It was a promise not to look.

So the deployed architecture builds the restriction into the network itself. Two small Cloudflare Workers sit in front of one private bucket, and they split the read and write worlds completely.

The upload worker is write-only by construction. It has three endpoints (upload, exists, delete) and no route that returns an object's contents. That is not a permission that could be misconfigured one day; the code path simply does not exist. Even a caller holding the shared secret can do nothing but write a file, ask a yes or no question, or delete. Every request is authorised by a short-lived HMAC token minted by Moodle, and the token pins everything about the operation it permits:

const KEY_PATTERN = /^photoid\/\d+\/\d+\/[a-f0-9]{32}\.(png|jpe?g|pdf)$/;
const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'application/pdf'];
const MAX_BYTES = 10485760;

const payload = `upload|${key}|${contentType}|${contentLength}|${exp}`;
if (!await verify(env, payload, sig)) {
    return json({error: 'Bad signature'}, 403);
}

await env.BUCKET.put(key, request.body, {httpMetadata: {contentType}});

The action prefix means an upload token can never be replayed as a delete. The exact byte size and content type are part of the signature, so the file that arrives must be the file that was announced. Upload tokens live for fifteen minutes; the server-to-server tokens for existence checks and deletions live for two. And the object key bakes in the owner, photoid/{userid}/{courseid}/{32 hex random}.{ext}, so when the form comes back, Moodle can verify with a regex that the key belongs to the submitting learner on that course, then make one boolean /exists call to confirm the upload actually landed. All without ever being able to see the file.

The browser talks to the edge, not to Moodle. The trick that makes this work inside Moodle's venerable form framework is almost comically small: the file input has no name attribute, so the browser never includes the file bytes in the form post at all. A little dependency-free JavaScript asks Moodle for a token, which Moodle only mints for an enrolled learner who is genuinely facing the form, sends the file straight to the worker, and drops the resulting object key into a hidden field:

fetch(M.cfg.wwwroot + '/local/dbsregulated/upload_url.php', {
    method: 'POST',
    body: params,
    credentials: 'same-origin'
}).then(function(response) {
    return response.json();
}).then(function(data) {
    var target = data.url
        + '?key=' + encodeURIComponent(data.key)
        + '&exp=' + encodeURIComponent(data.exp)
        + '&sig=' + encodeURIComponent(data.sig);
    return fetch(target, {
        method: 'PUT',
        headers: {'Content-Type': data.contenttype},
        body: file
    }).then(function(response) {
        if (!response.ok) {
            throw new Error('Upload failed: HTTP ' + response.status);
        }
        keyfield.value = data.key;
        namefield.value = file.name;
    });
});

The ID travels learner to Cloudflare to bucket. Moodle receives a key and a filename and nothing else. Not even a temporary file on the PHP side.

Reading is a different worker, behind a different door. The viewer worker is the only read path to the bucket, and its route sits behind Cloudflare Access with a named email allowlist. Reviewers sign in with a one-time PIN sent to their inbox, no Cloudflare account needed, and being a Moodle administrator grants nothing, because the allowlist is managed in Cloudflare, entirely outside Moodle. The review page in Moodle deliberately shows no image at all: just an "Open ID in secure viewer" button.

And because "protected by a toggle in a dashboard" is not the same thing as "protected", the viewer independently validates Cloudflare's Access JWT on every single request: issuer, audience, expiry, and the RSA signature against the team's published signing keys:

const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
if (!audiences.includes(ACCESS_AUD) || payload.iss !== TEAM_DOMAIN
        || !payload.exp || payload.exp < now) {
    return null;
}

const cryptoKey = await crypto.subtle.importKey('jwk', jwk,
    {name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256'}, false, ['verify']);
valid = await crypto.subtle.verify('RSASSA-PKCS1-v1_5', cryptoKey, signature,
    new TextEncoder().encode(parts[0] + '.' + parts[1]));

If the Access application were ever deleted or misconfigured, the worker refuses everyone rather than quietly serving anyone. It handles Cloudflare's signing key rotation gracefully, with a cached key set and one forced refetch when an unknown key ID appears, and it serves the learner-supplied bytes with nosniff and a sandboxing Content-Security-Policy, because an uploaded "PDF" is still untrusted input and should never be allowed to run anything.

And then the documents remove themselves. A lifecycle rule on the bucket deletes every object ten days after upload, whether reviewed, unreviewed, or abandoned mid-form. That timer is not a cron job I wrote; it is enforced by the storage platform itself, so it keeps working even if every line of my code is switched off. Reviewers also get a "Delete ID document" button to remove a file the moment it has served its purpose, rather than waiting out the clock. The PDF pack sent to the awarding organisation deliberately excludes the ID document too. Verification is the requirement. Retention is not.

One nuance I am fond of: the drawn signature does not go through any of this. It is captured on a canvas, validated as a genuine image, and stored as an ordinary Moodle file, because a signature squiggle is not an identity document, and pretending otherwise would only add failure modes. Knowing which data deserves the bunker is as much a part of the design as the bunker.

Notifications Are the Doorbell, Not the Record

A registration only counts once a person has reviewed it, so the team needed to hear about new submissions reliably. When a form arrives, a message posts to the team's Discord and an email goes to a configurable list of addresses. If the Discord post fails (webhooks do die) the email carries a warning saying so, which means a dead webhook is noticed the same day instead of months later. Behind both sits the real backstop: a daily scheduled task that keeps reminding, on both channels, for as long as any submission has been waiting more than 24 hours.

The principle is that notifications are pointers and the review queue is the record. A submission can never be lost, only unnoticed, and the reminder task caps "unnoticed" at a day. Every channel also has a one-click test page in the admin settings, including an email test that can print the full conversation with the mail server. The Moodle site sits on managed hosting without server-level access, so diagnostics had to live inside the plugin, where the people running the site can actually use them.

The Review Queue

Submissions land in a tabbed queue: pending, approved, rejected, re-registration required, plus an archived view. Approving unlocks the course and emails the learner. Rejecting requires written notes, which go to the learner by email so they know exactly what to fix, and they can resubmit. The fresh attempt supersedes the old one, which moves itself to the archive tab with its eventual outcome shown alongside. There is also a revoke path for the rare case where an approval has to be withdrawn later, which sends the learner back through a fresh form.

This queue is also what "one central place" means in practice. Everything arrives in the same list, every submission shows its status and its reviewer, and the whole team works from the same view of what is pending and what is done.

The three learner-facing emails (approved, rejected, re-register) are editable in the plugin settings using placeholder tokens like {coursename} and {notes}, and the settings page validates those placeholders on save. A typo like {coursname} is rejected with a clear message instead of being emailed to a learner literally. Those three emails are, deliberately, the only emails a learner ever receives from either plugin.

The Second Plugin: a 182-Day Clock

Approval starts a clock. Learners on regulated courses have six months, a flat 182 days, to complete, and local_dbscompletion tracks those deadlines for staff. The moment a registration is approved, the first plugin fires a Moodle event, the second plugin observes it, and the learner appears in the tracker with their deadline already calculated. Staff see every deadline in one sortable place, with nothing to remember and nothing to work out by hand.

Notifications go to per-stage Discord channels and email: four weeks before the deadline, on deadline day, and two weeks after if there is still no completion or extension recorded. The stage logic sends at most one notification per learner per day and always picks the most severe unsent stage, so a learner added to the tracker late does not set off a backdated flurry. Extensions are recorded as a revised deadline and restart the cycle against the new date, including a fresh four-week warning.

Two things this plugin pointedly does not do: it never contacts learners, and it never touches course access. Chasing, extensions, and any suspension decisions stay with staff. That was the design, not a limitation. A tracker that occasionally pesters staff is useful; one that locks a paying learner out of their course on a timer is a liability. The two plugins are also loosely coupled: each installs, works, and uninstalls perfectly well without the other, connected only by that one event.

Privacy as a Feature, Not a Footnote

Both plugins ship full implementations of Moodle's privacy API, so a subject access request exports everything held (every form field, the signature, and declarations of the external storage locations) and a GDPR deletion request reaches all the way out to the bucket and removes the stored ID through the worker. Retention is short and automatic, storage is minimised, and access to identity documents is limited to a short, named allowlist rather than a role that accumulates members over time.

The build also shipped with its paperwork: a data protection impact assessment, a learner-facing privacy notice, and a reviewer confidentiality agreement, alongside an administrator's guide written in plain English for the people actually reviewing forms. What the buttons do, what the emails say, and what to check when something looks off. Releases are versioned, verified on a staging site before touching production, and logged in a plain-English changelog the team can read. A system that handles identity documents is only half code. The other half is being able to show your working.

Meta

  • Year: 2026
  • Skills: Moodle, PHP, Javascript, Cloudflare Workers, Cloudflare R2, Cloudflare Access

Related Projects

  • Dog Business School
  • DogBusiness.co.uk