Moodle Cohort Memberships Never Expire. Here's How We Make Them Lapse After a Year

Cohort sync deliberately has no expiry, so annual-access models rot into permanent access. This is the backlog query, the guarded removal script we run in production, and the free plugin we're building from it.

Moodle cohort memberships are permanent by design — the cohort sync enrolment method deliberately implements no expiry (MDL-53236). The fix is a cron-driven CLI script that deletes mdl_cohort_members rows whose timeadded is older than 365 days, in small guarded batches, then lets Moodle's own enrol_cohort_sync task drop the orphaned course enrolments.

If you run annual compliance training in Moodle™, you eventually hit the trap: cohort membership has no end date. Learners you added in August 2025 are still there in August 2026, still enrolled in everything the cohort touches, and nothing in core will ever remove them.

You've probably searched some version of "moodle cohort membership expire after one year" or "auto unenrol cohort members after 365 days moodle" and found forum threads that trail off around 2016. This post is the complete answer: why the gap exists, how to measure your backlog, and the guarded removal script we run in production — including the incident that taught us why the guards matter.

Our reference site: Moodle 4.x, about 55 cohorts, about 630 cohort memberships, an annual-training model where every learner gets one year of access from the day they join. Then access should lapse.

It doesn't. Here's why.

Root cause

Why Moodle cohort memberships never expire

A cohort membership is a row in mdl_cohort_members. The row records who, which cohort, and when — the timeadded column — and then nothing in core ever reads that timestamp again for expiry purposes. Memberships are permanent until something removes them by hand.

The enrolment side is where people expect the fix to live, and it deliberately isn't there. Cohort sync enrolment (enrol_cohort) has no per-user end date and does not implement process_expirations(). That's a design decision, tracked in MDL-53236: a cohort is a static list of people, not a time-boxed enrolment, so the sync plugin syncs the list and refuses to reason about time. Setting an enrolment duration on the sync method accomplishes nothing.

We think that's a defensible call for core. But it means "everyone in cohort X gets one year of access" — the single most common compliance-training shape we see — has no supported implementation at all. Memberships pile up forever, and access never lapses.

day 0 · timeadded writtenday 365foreverMDL-53236: no expiry check exists hereCore Moodlemembership + every synced course enrolment persist indefinitelyWith the expiry jobone year of accessrow deleted → sync unenrolsexpiry gate (daily cron)
The mechanism in one picture: core has no gate at day 365. The fix inserts one — on the membership row, not the enrolments.

Diagnosis

How big is your backlog? One query

Because timeadded is a plain Unix timestamp, "one year old" is simply timeadded < (now - 365*86400). Run this against your database to see how many memberships are already overdue:

SELECT COUNT(*) FROM mdl_cohort_members
WHERE timeadded < UNIX_TIMESTAMP() - 365*86400;

On a site that has been running an annual model without an expiry mechanism, this number is usually uncomfortable. That's fine — the fix below is designed to work through a backlog gradually rather than in one destructive pass.

One check before you go further: confirm the cohorts in question actually drive enrolments through cohort sync (a row in mdl_enrol with enrol='cohort' and customint1 pointing at the cohort). If your cohorts exist only for audience-targeting dashboards, removing members changes visibility, not access, and this whole article is lower-stakes for you.

The fix

A batched CLI script that removes expired memberships safely

The core design decision: delete the mdl_cohort_members row and touch nothing else. Don't unenrol anyone yourself. Moodle's own scheduled enrol_cohort_sync task notices the orphaned enrolments on its next pass and applies each course's configured external-unenrol action. You get exactly the behaviour a manual cohort removal would produce, because mechanically it is a manual cohort removal — just scripted.

The script runs from cron as a Moodle CLI script. The guardrails are not optional decoration; every one of them exists because of something that went wrong or nearly did:

  • Batch cap (--max-rows): bounds the blast radius per run — we use 50 a day.
  • Cohort exclusions: skip listed cohort IDs (sampler cohorts, staff groups).
  • Role guard: never touch a user holding an elevated role anywhere on the site — manager, course creator, teacher, and so on.
  • Email guard: never touch accounts on your internal staff domain. Belt and suspenders on top of the role guard.
  • Siteadmin guard: never touch anyone in $CFG->siteadmins.
  • Pause file: if a designated file exists, the run is skipped entirely. An emergency stop that doesn't require editing crontab.
  • Lock: a \core\lock so the job can't overlap itself or a panicked manual run.
  • Transactional delete, logged: every removed row is logged with id, user, cohort, and join date.

The heart of it is the eligibility query. Parameterised, it looks like this:

$where = [
    'cm.timeadded < :cutoff',
    'cm.cohortid ' . $notinsql,                    // excluded cohorts
    // only cohorts that actually drive a student-role cohort enrolment:
    "EXISTS (SELECT 1 FROM {enrol} e
              WHERE e.enrol='cohort' AND e.customint1=cm.cohortid AND e.roleid=5)",
    // never remove elevated-role users:
    "NOT EXISTS (SELECT 1 FROM {role_assignments} ra
                  WHERE ra.userid=cm.userid AND ra.roleid IN (1,2,3,4,...))",
    // never remove internal/staff emails:
    "NOT EXISTS (SELECT 1 FROM {user} u
                  WHERE u.id=cm.userid AND (u.email LIKE '%@your-staff-domain.example'))",
];
// + exclude $CFG->siteadmins by id

Two notes on that. roleid=5 is the stock student role and customint1 is where enrol_cohort stores the cohort id — both standard, but verify on your site. And don't hardcode the elevated-role id list like the placeholder suggests: resolve the ids from mdl_role by shortname, because sites with custom roles will differ.

The delete itself, batched and transactional:

$targets = $DB->get_records_sql(
    "SELECT cm.id ... WHERE $where ORDER BY cm.timeadded ASC",
    $params, 0, $maxrows);

$tx = $DB->start_delegated_transaction();
$DB->delete_records_list('cohort_members', 'id', array_keys($targets));
$tx->allow_commit();
// do NOT sync inline; Moodle's scheduled enrol_cohort_sync picks up orphans.

Ordering by timeadded ASC means the most-overdue people leave first, which is what a compliance auditor would expect.

cohort_members rowswith timeadded olderthan 365 daysguards (never touch)• excluded cohort ids• elevated roles anywhere• staff email domains• siteadmins / pause fileskipped rows — logged, left untouchedbatch cap--max-rows = 50transactionaldelete + lognext scheduled enrol_cohort_sync rundrops the orphaned course enrolments
The removal pipeline. Everything left of the delete is a reason not to remove someone; the actual unenrolment is left to Moodle itself.

Verification

How to know it's working

Three layers, in order of paranoia:

Dry-run first. The script's dry-run mode prints the exact rows it would delete — user, cohort, join date — and touches nothing. Read the list. The first time we did this, the list is what exposed a guard we hadn't thought of yet (more on that below).

Watch the burn-down. In production, log the eligible count on every run. With a 50-row batch cap it falls by the batch size each day until the backlog is cleared, then settles at steady state — a handful per day, matching the natural rate at which memberships age past a year.

Recount independently. Run the diagnosis query from earlier, outside the script, and confirm zero eligible rows remain that aren't guard-protected. Don't trust the tool's own log as the only evidence.

eligible rowsdaily cron runsinitial backlog−50 rows per run (batch cap)steady state: a handful per day
The shape you want in the log: a steady stair-step down, then a flat trickle. A cliff means your batch cap isn't working.

Field notes

Gotchas, and the incident that shaped the guards

What went wrong, honestly. An early version of this script had no role guard and no email guard. It removed staff accounts that had been dropped into a student cohort for QA — people checking the learner experience from the inside. They lost course access mid-review, and we spent a morning re-adding them and apologising. Nothing was permanently lost, because removal cascades through cohort sync rather than deleting data — but it's the reason the role guard, the email guard, and the dry-run mode exist. If you build your own version, build the guards first, not after.

Remove vs suspend. The script removes the membership; what happens to each course enrolment is then decided by that enrolment instance's external-unenrol-action setting — fully unenrol, or suspend and keep roles. Suspend is the audit-friendly option, since grades and completion records stay in place. Decide this per course before switching the script on, because it's the difference between "access paused" and "grades moved to history."

Re-adds reset the clock. Re-adding a user writes a fresh timeadded, so they get another full year. For annual training that's correct behaviour — re-enrolment is a new cycle — but document it, or someone will file it as a bug.

Cron downtime is harmless. Eligibility is "older than N days," not "expires today," so a missed run just means a slightly bigger pool next time — and the batch cap prevents a thundering-herd unenrolment after an outage. No state is lost.

One cohort, many courses. A single cohort can drive enrolments in dozens of courses. Removing the one membership row cascades cleanly to all of them via the sync task, which is precisely why deleting the row is better than scripting unenrolments course by course.

Coming soon

We're packaging this as a free plugin: local_cohortexpiry

As far as we can find, nothing in core or the Moodle plugin directory does time-boxed cohort membership. So we're turning our production script into a proper plugin, local_cohortexpiry: a scheduled task, a settings page (expiry days, excluded cohorts, role and email exclusions, batch cap, remove-or-suspend action, dry run), an admin report of upcoming expirations, and events you can hook. The exclusion model from the incident above is baked in, not bolted on.

It will be released free on our plugins page. If you want it before the public release — or you want us to sanity-check your cohort setup against it — send us a note and we'll get you an early build; we reply within one business day. And if your expiry rules are stranger than "365 days for everyone" (tiered access, per-cohort windows, grace periods), that's the kind of thing our Moodle development service builds to order.

Everything above is enough to solve this yourself today, and we'd genuinely rather you did than wait on us. The plugin just means you won't have to maintain it.

Quick answers

Questions people ask about this

Can Moodle expire cohort memberships automatically?

No. Cohort memberships are permanent until something removes them. The cohort sync enrolment method deliberately does not implement expiry (tracker issue MDL-53236), because a cohort is treated as a static list of people rather than a time-boxed enrolment. Any expiry behaviour has to come from a custom script or a plugin.

What happens to course enrolments when a user is removed from a cohort?

Moodle's scheduled cohort sync task notices the missing membership on its next run and applies each course enrolment instance's external unenrol action: either fully unenrol the user or suspend them and keep their roles. One cohort can drive enrolments in many courses, and the removal cascades cleanly to all of them.

Does removing someone from a cohort delete their grades?

Not immediately. If the unenrol action is set to suspend, everything stays in place and only access is cut. If it fully unenrols, grades move to Moodle's grade history and can be restored on re-enrolment when grade recovery is enabled. For compliance sites we usually recommend suspend, so audit records stay visible.

If a user is re-added to a cohort, does their one-year clock restart?

Yes. Re-adding a user writes a fresh timeadded value in mdl_cohort_members, so they get a full new year from that date. For annual-training models that is exactly the behaviour you want, but document it so nobody reports it as a bug.

Why not just set an enrolment duration on the cohort sync method?

Because cohort sync ignores it. The enrol_cohort plugin has no per-user end date and never runs Moodle's expiration processing, so a duration on the sync method does nothing. That is by design, which is why the working fix operates on the membership row itself instead of the enrolment.

Is there a ready-made plugin for cohort expiry?

Not in the Moodle plugin directory as far as we can find. We are packaging our production script as a free plugin called local_cohortexpiry, with a settings page, dry-run mode, guard exclusions, and an upcoming-expirations report. It will be released on the Sternfast plugins page, and you can contact us for an early build.