Give Moodle learners access until a date — without cohort sync
Cohort sync has no end date field, and re-adding people to the cohort just restarts the clock. Here is the enrolment channel that does carry a deadline — with the exact code, and the verification we ran before pointing it at 480 live learners.
You need to give a learner — or 480 of them — access to a course until a specific calendar date, after which the door closes on its own. If those learners came in through cohort sync, Moodle™ gives you no field for that. There is no end date to set on a cohort enrolment. There never will be.
People google this as "moodle enrolment expire on specific date", "give access until a date moodle", and the more resigned "cohort sync no end date". The usual forum answer is a scheduled task or a spreadsheet reminder to suspend people by hand. Neither is necessary.
Our version of the problem: roughly 480 learners across 4 courses had just been expired by an annual cohort-expiry job — correctly — but a contract said they were owed access until a fixed end date a few months out. So we needed access restored, bounded by that exact date, and invisible to the very expiry machinery that had just removed them.
Re-adding them to the cohort would have restarted the annual clock. That was off the table.
The fix is one function call, and Moodle enforces the deadline itself. This post is the whole thing: why cohort sync can't do it, which enrolment method can, the copyable script, and how to prove the shutoff actually happens before you trust it at scale.
Root cause
Why can't cohort sync set an enrolment end date?
Because a cohort is a list, not a schedule. The enrol_cohort plugin mirrors cohort membership into course enrolments: you're in the cohort, you're in the course. It deliberately does not implement process_expirations() — this was raised and closed as MDL-53236 — because a per-user end date makes no sense for an enrolment whose only job is to reflect a membership list. Every row cohort sync writes into mdl_user_enrolments has timeend = 0. Forever.
That leaves two tempting non-fixes:
- Re-add to the cohort. If you (like us) run any expiry logic off
mdl_cohort_members.timeadded, re-adding writes a fresh timestamp — the learner gets another full year, not access-until-a-date. Wrong duration, wrong mechanism. - Write a cron job that suspends people on the date. It works, but now you own a moving part: it has to fire on the right day, someone has to notice if it doesn't, and someone has to remember it exists in two years. We wanted a channel where the deadline is a property of the enrolment itself, not of a script that hopefully runs.
The right channel
Which Moodle enrolment method supports a per-user end date?
Manual enrolment. The enrol_manual plugin writes a per-user timeend into mdl_user_enrolments, and it is a completely separate plugin from enrol_cohort — separate instance in mdl_enrol, separate rows, separate lifecycle. Any cohort-expiry job you run, native sync included, cannot see it and cannot touch it.
The part that surprises people: the expiry needs no cron job of yours. Moodle checks timestart/timeend at read time — require_login() calls down into is_enrolled() with the active-only flag, and the instant now > timeend that check flips to false. Access dies at the deadline whether or not any scheduled task ever runs. Nothing to schedule, nothing to monitor, nothing to revert.
Before changing anything, look at what channels a learner currently has in the course:
SELECT e.enrol, ue.status, ue.timestart, ue.timeend
FROM mdl_user_enrolments ue
JOIN mdl_enrol e ON e.id = ue.enrolid
WHERE ue.userid = :userid AND e.courseid = :courseid;
Cohort rows will show timeend = 0. After the fix you'll see a second row, enrol = 'manual', with your cutoff timestamp in timeend. Both can coexist — more on that in the gotchas.
The fix
Enrol via enrol_manual with timeend set to the cutoff
The core is four lines:
$plugin = enrol_get_plugin('manual');
$inst = $DB->get_record('enrol', ['courseid'=>$cid, 'enrol'=>'manual']);
$plugin->enrol_user($inst, $uid, 5 /*student*/, time(), $cutoffTs,
ENROL_USER_ACTIVE, true /*recovergrades*/);
Argument by argument: the manual enrolment instance for the course, the user id, the role id, timestart (now), timeend (the cutoff timestamp — this is the whole point), an active status, and recovergrades = true, which restores any grades the learner earned before a previous unenrolment from mdl_grade_grades_history. For people whose access lapsed and is now being restored, you want that flag on.
Check the role id. Role 5 is the student role on a stock install, but roles are site-specific. Confirm with SELECT id, shortname FROM mdl_role WHERE shortname = 'student'; before running anything in bulk.
As a complete CLI script for a batch of course/user pairs:
<?php
define('CLI_SCRIPT', true);
require('/var/www/moodle/config.php');
$cutoffTs = (new DateTime('2026-12-31 23:59', new DateTimeZone('America/Chicago')))
->getTimestamp();
// [courseid, userid] pairs — from the contract list, a CSV, wherever.
$pairs = [[101, 2044], [101, 2045] /* ... */];
$plugin = enrol_get_plugin('manual');
foreach ($pairs as [$cid, $uid]) {
$inst = $DB->get_record('enrol',
['courseid' => $cid, 'enrol' => 'manual'], '*', MUST_EXIST);
$plugin->enrol_user($inst, $uid, 5 /*student*/, time(), $cutoffTs,
ENROL_USER_ACTIVE, true /*recovergrades*/);
mtrace("enrolled user {$uid} in course {$cid} until {$cutoffTs}");
}
MUST_EXIST is there because a course where someone deleted the manual enrolment method will otherwise hand you false and a confusing fatal later. Every stock course has a manual instance; the ones that don't are exactly the ones you want the script to stop on.
Leave the manual enrolment instance's expiry action (expiredaction) at "Keep user enrolled". With that setting, the row simply lingers inactive after the deadline — no cron of yours required, nothing to revert, and the learner's records stay attached to the course for reporting.
Verification
How do I verify the access actually shuts off?
Do not take the mechanism on faith — we didn't. Before touching the 480, we ran a lifecycle test on a scratch account with timeend = now + 90 seconds:
// scratch account, deadline 90 seconds out
$plugin->enrol_user($inst, $testuid, 5, time(), time() + 90,
ENROL_USER_ACTIVE, true);
var_dump(is_enrolled($ctx, $u, '', true)); // bool(true) — before the deadline
// ... wait 90 seconds ...
var_dump(is_enrolled($ctx, $u, '', true)); // bool(false) — access denied
var_dump(is_enrolled($ctx, $u, '', false)); // bool(true) — still visible to reporting
That split is the behaviour you're buying, proven on live data: is_enrolled($ctx, $u, '', true) — the active-only check that gates actual access — flips to FALSE at the deadline, while is_enrolled($ctx, $u, '', false) stays TRUE so completion reports, grade exports, and audit queries still see the learner.
| Check | Before timeend | After timeend |
|---|---|---|
is_enrolled($ctx, $u, '', true) — gates access | TRUE | FALSE |
is_enrolled($ctx, $u, '', false) — reporting view | TRUE | TRUE |
Row in mdl_user_enrolments | present | present (inactive) |
Active before, auto-denied after, still enrolled-for-reporting. Then, and only then, scale.
Edge cases
Gotchas worth knowing before you run this
No welcome email storm. enrol_user() called from the CLI sends no welcome email — the "send course welcome message" behaviour lives in the enrolment form path, not the API. For a bulk restore of 480 people, that's exactly what you want: nobody gets a confusing "you've been enrolled" notice for a course they were in last month.
Layering on top of an existing cohort enrolment is fine. A user can hold a cohort enrolment and a manual-with-timeend enrolment in the same course simultaneously; access is the union of active channels. When cohort sync later removes the cohort side — expiry job, membership change, whatever — the manual enrolment keeps access to the deadline, because cohort sync only unassigns its own component roles. The two channels genuinely don't interact.
Don't set the expiry action to unenrol. If you flip expiredaction to "Unenrol user from course", the enrolment cron will actually remove the enrolment at the deadline — and depending on your grade history settings, that shoves grades into mdl_grade_grades_history and empties the gradebook view. Recoverable (that's what the recovergrades flag is for on a future re-enrol), but "Keep" avoids the whole excursion.
Re-adding to the cohort still restarts the clock. The manual channel doesn't change this. If a support person later "helpfully" re-adds one of these learners to the cohort, mdl_cohort_members.timeadded is fresh and any duration-based expiry logic grants a new full term. Document who owns cohort membership, or the contract date and the cohort clock will fight.
Choosing the right tool
What if it's a whole cohort that should expire every year?
Then this is the wrong post — deliberately. enrol_manual + timeend is the right shape for a fixed calendar date applied to a known list of people: a contract end, a pilot window, a grace period. It is the wrong shape for "every member of these cohorts gets N days from the day they joined" — you'd be hand-computing hundreds of individual deadlines that a membership-driven job should own.
For that standing policy — the one that expired our 480 in the first place — you want a batched, guardrailed cleanup job keyed on mdl_cohort_members.timeadded. We wrote that one up separately: making Moodle cohort memberships expire after a year. The two approaches compose: the cohort job handles the population-level rule, and manual-with-timeend handles the exceptions the rule can't express.
If you'd rather hand someone the contract and the learner list
Everything above is enough to do this yourself — the test-then-scale sequence included, and we'd suggest not skipping it. But if your enrolment setup has accumulated layers nobody fully trusts anymore, or you want the date-bounded access wired up with logging and a rollback path, this is squarely the kind of scripting our Moodle development work covers. Tell us what the access rules are supposed to be and we'll tell you which enrolment machinery should own each one — we reply within one business day.
Quick answers
Questions people ask about this
Can a cohort sync enrolment have an end date in Moodle?
No. The enrol_cohort plugin has no per-user end date and deliberately does not implement enrolment expiry — the request was raised and closed as MDL-53236. Every enrolment row cohort sync creates has timeend set to 0, so access continues until the user leaves the cohort or the sync is removed.
What happens when a Moodle enrolment reaches its timeend?
Access is denied immediately, because Moodle checks timestart and timeend at login time rather than via a cron job. With the enrolment method's expiry action left at Keep, the enrolment row simply becomes inactive: the learner can no longer enter the course, but their grades and completion records remain visible for reporting.
Does enrol_user() send a welcome email when run from the CLI?
No. The course welcome message is sent by the enrolment form path in the UI, not by the enrol_user() API call. That makes CLI scripting safe for bulk enrolments — restoring access for hundreds of learners will not trigger a wave of enrolment notification emails.
Can a user have both a cohort enrolment and a manual enrolment in the same course?
Yes, and it is a supported, useful combination. Access is the union of the active enrolment channels. If cohort sync later removes its enrolment, the manual enrolment keeps the learner's access until its own timeend, because cohort sync only unassigns the roles it created itself.
Do learners lose their grades when a manual enrolment expires?
Not if the expiry action is left at Keep, which leaves the enrolment row in place inactive with all grade and completion data attached. Grades only move into grade history if the user is actually unenrolled. Even then, re-enrolling with the recovergrades flag set to true restores them from mdl_grade_grades_history.
How do I expire access for a whole cohort after a fixed duration instead of a fixed date?
That is a different problem and needs a different mechanism. Per-user manual end dates do not scale to a standing rule like one year of access from the join date. For that, run a scheduled, batched cleanup job keyed on the timeadded column of cohort_members, with guardrails such as role exclusions, batch caps, and a dry-run mode.