Turn any Space into a course platform — build, enroll, learn, certify.
models/): plain ActiveRecords on lms_* tables. Course holds the
status/level/enrollment-type constants and the option lists used by all forms.
There is intentionally no default scope on Course::find() — a default scope
would also apply to relations ($enrollment->course would return null for
archived courses). Queries that must hide archived courses filter on status
explicitly.controllers/): all Space-scoped controllers extend
ContentContainerController; every loader verifies the record belongs to the
current Space (IDOR protection). Instructor actions are gated by the
ManageCourses permission.status = published and publish_at is in the future. It becomes visible to
students automatically once publish_at passes — no cron needed.Space::inviteMemberByEMail(), gated on
InviteUsers + the site's auth.internalUsersCanInviteByEmail). Core's
user_invite row cannot carry the course — it holds one space_invite_id, is
unique per email platform-wide, and is deleted at approval — so the course is
recorded as a CourseEmailInvite and redeemed by
Events::onSpaceMemberAdded() (on Membership::EVENT_MEMBER_ADDED) when they
join. That handler enrolls regardless of enrollment_type and without the
max_students cap: the instructor's CSV row is the enrollment decision.lesson/complete
rejects completion until a passed attempt exists.JavaScript policy: the module is server-rendered — plain links and POST
forms everywhere (curriculum builder, player navigation, enrollment,
instructor actions all redirect with flash messages). Confirmation on
destructive submit buttons uses humhub\widgets\bootstrap\Button::confirm()
(core's CSP-safe data-action-confirm handling), so no module JS is needed
for that. JS is kept only where a page can't work without it:
humhub.lms.player.js — video watch-time tracking (below).views/quiz/take.php — countdown timer with auto-submit, and a
submit-guard shared between the manual submit button and the auto-submit
(both registerJs) to stop a double-submit reaching the server's
resubmission-rate-limit response as a raw page.views/quiz/manage.php — client-side question builder.views/instructor/edit.php — drag-and-drop reordering of sections and
lessons (registerJs). It fills in and submits hidden POST forms the list
already renders. This is the only way to reorder a curriculum, so it is
the one part of the page that does not work with JS disabled.Auto-submitting a filter/setting field on change is not one of these
exceptions: core already handles it declaratively. views/course/index.php's
category/level/language controls carry
'data-action-change' => 'ui.form.submit' (see
protected/humhub/modules/admin/views/setting/topics.php for another example)
instead of a module script — core's humhub.action/humhub.ui.form bind a
delegated, CSP-safe listener globally, so no per-page JS is needed. The text
search deliberately does not carry it — submitting per keystroke would fire a
request per character — and instead gets a plain type="submit"
magnifying-glass button overlaid on the field; Enter already submits the
form natively. The "Filter" button remains as a fallback for browsers/setups
where the delegated listener never runs.
instructor/_form.php and
lesson/_editor.php (cosmetic only; the forms work without them).SuneditorAssets (assets/SuneditorAssets.php) must not defer its JS.
humhub\components\assets\AssetBundle defaults every asset's $js to
defer unless told otherwise. SuneditorField::run() immediately follows
the bundle's registration with a plain (non-deferred) inline
SUNEDITOR.create(...) call. On a genuine full page load — anything that
isn't a PJAX navigation, including a plain refresh — a defer script only
runs once the whole document has finished parsing, but a non-deferred
inline script runs immediately as the parser reaches it; the inline call
fired before suneditor.min.js had loaded, throwing "SUNEDITOR is not
defined" and leaving only the bare textarea visible. This was invisible
when only testing via PJAX links, because scripts PJAX inserts via DOM
patching don't honour defer at all (it only has meaning during initial
HTML parsing), so both scripts happened to execute in insertion order
there regardless. Fixed by setting public $defer = false; on the bundle.resources/js/humhub.lms.player.js): the postMessage
protocols of the official players are spoken directly, so no third-party script
is loaded (YouTube's iframe_api cannot be vendored — it is a loader that
fetches a build-versioned file from youtube.com, which would also mean a request
to Google before playback and defeat the youtube-nocookie embed).?enablejsapi=1&origin=… and the official
{event:'listening', channel:'widget'} handshake. State arrives
incrementally: initialDelivery carries duration, later infoDelivery
messages carry only currentTime, so duration is cached across messages.timeupdate (playProgress and ?api=1 belong to
the retired Froogaloop API). The privacy hash of an unlisted video must be
kept as ?h=… or the embed 404s.event.origin matches the iframe's own
origin, and outgoing ones target that origin rather than '*'.<video> and tracked via timeupdate.
If a video is not trackable (unknown host), the button is not locked.watchedPercent
is reported by the client and cannot be verified; real enforcement would need
a heartbeat endpoint, and a scripted heartbeat would defeat that too./lms/certificate/verify?token=…) works without login. Field lengths are
clamped (mb_strimwidth) and the frame height is tuned so the certificate
always fits one A4 landscape page — raising min-height or the constructor
margins pushes the footer onto a second page and clips the frame border.
Page margins live in the Mpdf constructor, not in an @page rule.models/Configuration, surfaced at
/lms/config/index) and per Space (models/ModuleSettings, surfaced at
/lms/space-config/index). Module::isCertificatesEnabled(?Space) and
Module::isRatingsEnabled(?Space) AND the two, the platform setting being the
master switch — a Space cannot opt back into something disabled globally.
Both default to enabled, and both hide/refuse rather than delete: turning
ratings off keeps the lms_course_rating rows, so re-enabling restores the
averages intact. Gate on the server side too (see CourseController::actionRate()
and CertificateController) — hiding the UI is presentation, not the check.
Orphan cleanup in IntegrityCheckHelper deliberately runs regardless of these
gates.ModuleSettings::coursesLabel(?ContentContainerActiveRecord)
returns the Space's custom label, or the translated default when it is empty.
Every user-facing use of the word goes through it — Space menu tab, catalogue
heading, breadcrumbs, page title, sidebar — so Yii::t('LmsModule.common', 'Courses')
should not be called directly outside that method. The custom value is a literal
chosen by a Space admin and is deliberately not translated; only the fallback is.ManageCourses on the Space (LessonCommentNotification, linking straight to
the lesson player so they can reply). Resolving that recipient set needs
CourseManagerHelper::findUsersQuery() because core has no users-by-permission
query for content containers: PermissionManager::findUsersByPermission()
walks the global group/group_user tables, which have no overlap with the
Space user-groups (owner/admin/moderator/member) that container
permissions are keyed on. The helper resolves the permission to a set of Space
user-groups once, then filters Membership::getSpaceMembersQuery() by
group_id — one query instead of a per-member permission check. Note
USERGROUP_OWNER is virtual (derived from space.created_by; the owner's own
membership row says admin), so it needs its own condition. sendBulk() skips
the originator, disabled users and duplicates on its own.autoScriptToLang/autoLangToFont) and Arabic joining plus bidi (useOTL).
Verified rendering: Latin, Cyrillic, Greek, Japanese, Chinese (simplified and
traditional), Korean, Thai, Devanagari, Hebrew, Arabic, Vietnamese.vendor/ grows from ~11 MB to ~96 MB, almost entirely
mpdf/mpdf/ttfonts (88 MB). If that ever needs trimming, Sun-ExtB.ttf
(17 MB, CJK Extension B), Aegyptus.otf, Aegean.otf and Akkadian.otf
(~9 MB combined, ancient scripts) cover nothing any living language needs —
but a composer install restores them, so pruning belongs in the release
packaging step, not the repo.@runtime/lms-mpdf and creates it on demand. It must be
writable in production or certificate generation fails.yii\helpers\HtmlPurifier
at render time. Video/embed lesson URLs end up as an iframe src and a
link href, so only absolute http(s) URLs are accepted on save
(Lesson::validateMediaUrl()) and anything else is refused again at render
time (Lesson::isHttpUrl()), for rows saved before the check existed.helpers/UploadHelper.php): every upload is checked against an
extension allow-list, a size cap, and its actual content (image header / %PDF-
magic bytes) — the accept attribute in the form is only a hint. Each file is
attached to its owning record with setPolymorphicRelation(), because
File::canView() otherwise falls back to creator-only for an unattached file.
Course and Lesson therefore implement ViewableInterface: without it
canView() would fall through to return true and hand the file to anyone
holding its GUID. Course files follow the same Space visibility rule as the
course pages (Course::isSpaceVisibleTo(): members only for a private Space,
any logged-in user for a public one, guests only with guest access on a Space
visible to everyone) — not membership, since non-members of a public Space
can browse and enroll; lesson files additionally require enrollment (or a
free-preview lesson, or manage permission).admin\permissions\ManageUsers permission (see e.g.
admin\controllers\UserController). ManageCourses (what lets an
instructor reach actionExportCsv() at all) is a Space-scoped permission
and implies neither, so that global permission is checked explicitly per
export rather than assumed from being an instructor.max_students check and the insert that follows it
are a read-then-write race, so they run while holding
Yii::$app->mutex (Course::enrollmentLockName()). Enrollment::findOrCreate()
is idempotent and treats the unique (course_id, user_id) index as the source of
truth, so a lost race resolves to the winning row instead of an integrity error.
Self-enrollment (CourseController::actionEnroll()) and approving a request
(EnrollmentRequestTrait::actionApproveRequest(), which leaves a refused
request pending) both check the cap under that lock; an instructor's own
enrollments (member picker, CSV import, email invites) deliberately do not.Enrollment::findOrCreate() reconciles on the way back in: a student who had
already finished would otherwise return at 100% with completed_at still null,
with no lessons left to complete and therefore no reachable certificate.actionDeleteLesson() (CurriculumManagementTrait) re-evaluates the whole
roster through CompletionHelper::reconcileCourse(). Same trap as re-enrollment
above: completed_at is only ever set while a student marks a lesson complete,
so once the lesson they never opened is gone there is nothing left for them to
click and they would sit at 100% forever with no certificate. The reconcile only
ever completes — a curriculum that grew never revokes a completion, and a
curriculum with no lessons left completes nobody (an empty course is one under
construction, not one everybody passed).
CompletionHelper::award() holds the side effects of completion (certificate +
notification) so that a course finished this way is indistinguishable from one
the student finished themselves; it fires strictly on the transition, which is
what Enrollment::markCourseComplete() returns.CurriculumManagementTrait calls warnEnrolledStudents(), and the
Curriculum tab carries the same warning permanently while the course has
students (views/instructor/edit.php) — before the change, which is when the
instructor can still decide not to. Both are silent for a course nobody is
enrolled in, i.e. for most of the time a course is being built.lms_quiz_attempt.answers is keyed by question id, so recreating the rows on
each save would orphan every past attempt's answers and make completed results
unreviewable. The builder round-trips each question's id and the controller
upserts, deleting only the ids that disappeared. Options and correct answers stay
JSON on the question row (positional indices) — fine at this scale, but note that
reordering or rewording options after attempts exist still changes what a stored
answer index means. Snapshotting the rendered question into the attempt is the
fix if that ever matters.actionDuplicate()
(CourseManagementTrait) copies the thumbnail and every PDF lesson's file into
a new File row (duplicateFile()) attached to the new record via
setPolymorphicRelation(), rather than reusing the source's guid — otherwise
the copy and the original would share one file's visibility and lifecycle,
same concern as the Uploads note above. Quiz questions get fresh ids too
(the new quiz has no attempts yet, so nothing depends on the old ones).
Enrollments, progress, quiz attempts, announcements and certificates are not
copied — the clone always starts as an empty draft.Link::post(...)->confirm(...) in this module also carries
->pjax(false), or the redirect after confirming is silently swallowed.
Button::run() renames data-method to data-action-method whenever
data-action-confirm (->confirm()) is also present — a documented
workaround for a different conflict ("data-method handler prevents
confirm... from being executed"). But data-action-method="POST" is read
by humhub.action.js's confirm handler, which — unless the element also
has data-pjax-prevent — issues the POST via client.pjax.post()
(humhub.client.pjax.js), i.e. jQuery-pjax, not a real form submission.
Yii's Response::redirect() detects the X-PJAX request header and
replies with an X-Pjax-Url header instead of Location, keeping status
302; jQuery-pjax's error handler only falls back to a real navigation for
GET, never POST, so that 302 is treated as a plain failed XHR and nothing
navigates — even though the server-side action completed correctly. The
symptom is exactly "click Duplicate/Delete, confirm, nothing visibly
happens, but a refresh shows it worked." ->pjax(false) (data-pjax-prevent)
routes the same click through client.submit() (humhub.client.js)
instead, whose ajax error handler explicitly handles a 302 and performs a
real navigation. Affects views/instructor/index.php's Duplicate/Delete
dropdown items and views/instructor/_form.php's Delete Course button —
any new confirm+POST link needs the same ->pjax(false).views/instructor/index.php, Link::none(...)->loader('Duplicating…')),
using core's data-ui-loader (resources/js/humhub/humhub.ui.loader.js) —
no module JS needed, it hooks any element generically. It relies on
actionDuplicate() always redirecting (success → the new course's edit
page, failure → the dashboard with a flash message) to clear itself; there
is no generic reset for a data-method link on a request that fails
without navigating at all, so if that action ever grows a non-redirecting
failure path, the link would need an explicit reset (or the data-action-*
framework, which does call loader.reset() for its own error path).migrations/m260730_130626_initial.php is
ON DELETE CASCADE down through sections → lessons → quizzes/questions and
enrollments → lesson progress/certificates, so actionDelete()
(CourseManagementTrait) only has to call $course->delete() for the row
data. That single call does not clean up attached Files, though:
the file module's delete-time cleanup (onBeforeActiveRecordDelete, core
file/config.php) only fires for records Yii actually loads and deletes
through ActiveRecord — the Course row itself qualifies (its thumbnail is
cleaned up automatically), but cascaded child rows like Lesson are wiped
directly in SQL and never instantiated, so their attached PDFs would
otherwise be orphaned. actionDelete() walks every lesson and deletes its
File::findByRecord() rows explicitly before deleting the course. Adding a
new per-lesson (or per-course) file attachment anywhere means adding it to
that same explicit cleanup — the DB cascade will not catch it.CourseRating::findOrNew()
looks up the existing row before creating one, so re-rating updates it in place
instead of accumulating rows that would skew the average — the unique
(course_id, user_id) index is the same idempotency shape as Enrollment.
Only students enrolled in the course may rate it.ContentActiveRecords. A lesson-level
Q&A could reuse HumHub's Wall/Comment system, but that would need Lesson to
gain Content rows, stream visibility and wall entries — a much bigger change
than the feature needs, and one that risks the IDOR/ViewableInterface
scoping described above for Lesson. LessonComment is instead a small,
self-contained list scoped to the lesson, gated by the same
enrolled-or-free-preview-or-manage rule as viewing the lesson itself.Course.discussion_enabled turns the whole Discussion section off, not
just posting. When disabled, LessonController::actionPlayer() skips the
LessonComment query entirely and the player never renders the section —
existing comments are left in the database untouched, so re-enabling brings
them straight back. actionAddComment() also re-checks it server-side
(defense in depth against a POST straight to that action while the UI is
hidden). Defaults to enabled (1) so existing courses keep behaving the
same after this column was added.SpaceSidebarWidget is student-facing only. Instructor actions (Create
Course, Instructor Dashboard) were removed from it — they're one click away
from the Space menu already, via the Instructor Dashboard itself, so
duplicating them here was pure clutter. What's left: a "Browse courses" link
with the published-course count, and — once the visitor has ≥1 enrollment
in the Space — enrolled/total, an overall progress bar (the average of
each course's own percent, not lessons-done over lessons-total across all
of them combined — a finished 2-lesson course and a barely-started
40-lesson course should each count as "one course", not let the big one
swamp the small one), certificates earned, a pending-request nudge, and the
3 most recently enrolled courses with their own percent and headcount.
innerJoin() with the real table name, not innerJoinWith(): the latter
joins lms_course without an alias in this query shape, so a condition
written against the relation name (course.space_id) fails at the DB with
"unknown column" — lms_course.space_id is what the generated SQL actually
has (same pattern already used in
InstructorController::actionIndex()'s pending-requests query).Per-course helpers (getCompletionPercent(), prerequisitesMet()) cost a query
or two each and must not be called in a loop. Batched equivalents resolve any
number of courses in a fixed number of queries and are what the list pages use:
Course::completionPercentsFor($courseIds, $userId)Course::prerequisitesMetFor($courseIds, $userId)Course::lessonCountsFor($courseIds)Course::completedLessonCountsFor($courseIds) — summed across enrolled studentsCourse::ratingSummaryFor($courseIds) — average + count, for the catalogue's starsCourse::studentsStartedCountsFor($courseIds) — distinct students with progress, for the delete-course warning$course->completedLessonCountsByUser($userIds) — for instructor rostersCourseProgressWidget is deliberately presentation-only and takes a percent;
it does not look the value up, because doing so was a query per rendered card.
Every Space-scoped controller extends components/BaseSpaceController, which
provides requireManage() plus loadCourse()/loadSection()/loadLesson().
Each loader resolves its record through the Space, so a record from another
Space 404s instead of being operated on. New actions should use these rather than
querying by primary key.
Models are loaded with load() in only two places (Course, Quiz); both
reassert their owning foreign key afterwards, since rules() validates those
columns as plain integers and would otherwise let a POST move the record into
another Space. Everywhere else foreign keys come from validated route parameters.
There is intentionally no default scope on Course::find() (see above), so each
call site filters status itself.
Events::onIntegrityCheck() hooks humhub\commands\IntegrityController::EVENT_ON_RUN
(registered in config.php), so php yii integrity/run validates every LMS
table. It's a one-line delegator to helpers/IntegrityCheckHelper::run(),
which holds the actual per-table checks — kept out of Events.php purely to
stop that file (event wiring: Space menu, sidebar) from being dwarfed by ~300
lines of integrity logic. Same pattern as modules_cuzy/ecommerce/Events.php's
handler; see that file for the canonical example this follows.
Most of these checks are defensive rather than expected to ever fire: every FK
in migrations/m260730_130626_initial.php already cascades, so a normal
delete never leaves an orphan row. Two things still get past that:
User::STATUS_SOFT_DELETED never actually removes
the user row, so no FK cascade fires — enrollments, progress, quiz
attempts, comments, ratings and requests belonging to such a user are
checked and offered for deletion explicitly.File rows deleted directly rather than through Course/Lesson) — the
guid is cleared rather than the record deleted, since the course/lesson
itself is still otherwise valid.It also fills in one legitimately missing case that isn't corruption: a
completed Enrollment with no Certificate (e.g. certificates were disabled
in the Space at completion time and re-enabled since) — Certificate::generate()
is idempotent, so offering it here can only ever fill a real gap, never
duplicate one.
lms_course_category is the one table with no dedicated AR model (a pure
pivot — see Course::getCategories()/CourseCategory::getCourses()), so its
orphan check uses a raw Yii::$app->db->createCommand()->delete() instead of
iterating records.
Deleting an orphaned Course (its Space no longer exists) reuses the same
file-cleanup loop as CourseManagementTrait::actionDelete() — the DB cascade
wipes lessons without ever loading them as AR, so their attached PDFs would
otherwise be orphaned on disk; see that action's docblock and the note above
under "Deleting a course is permanent" for why.
composer install installs mpdf/mpdf (certificate PDFs; see the note above on
why this engine). Requires the mbstring and gd PHP extensions. The vendor/
directory is shipped with the module but is not tracked in git.
Extract with:
php yii message/extract-module lms
Strings are split across per-screen categories rather than one big
LmsModule.base, which had grown past 400 keys — far too many for a translator
to work through, and impossible to review a diff of. Each category is kept under
roughly 50 keys.
| Category | Covers |
|---|---|
common | generic UI words used on three or more screens (Add, Cancel, Courses, Title) |
attributes | model attributeLabels() — field names and their hints |
options | enum/choice labels rendered in dropdowns and status badges (Draft, Beginner, Video) |
catalogue | course catalogue filter bar and empty states |
course | course detail page — enrolling, prerequisites, unenrolling |
rating | the course rating feature |
lesson | lesson player and its sidebar |
quiz | taking a quiz and its results |
quizAdmin | quiz/question management |
curriculum | curriculum builder — sections, lesson tree, sample content |
lessonEditor | the lesson editor panel |
courseForm | course settings form |
instructor | instructor dashboard and course header/actions |
students | student roster, enrollment and enrollment requests |
announcements | course announcements |
progress | "My Learning" progress dashboard |
certificate | certificates and the public verification page |
base | module name/description, Space menu and sidebar — whatever belongs to no single screen |
config · errors · notification · permissions | as before |
A given string lives in exactly one category, so translators never see it
twice and the two copies cannot drift. That is why a word used on several
screens goes to common (or to attributes/options if it is a field or enum
label) rather than being duplicated: when adding a string, check whether it
already exists elsewhere before picking a category.