Back to marketplace
225

LMS — Learning Management System

Buy now

Turn any Space into a course platform — build, enroll, learn, certify.

Architecture overview

  • Models (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 (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.
  • Scheduled publishing: a course is "scheduled" when status = published and publish_at is in the future. It becomes visible to students automatically once publish_at passes — no cron needed.
  • Email invites from CSV import: a CSV row that is an email with no account gets HumHub's own Space email invite (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.
  • Quiz gating: when a lesson has a quiz with questions, 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.

    • Tiny nonce'd show/hide toggles in 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.
  • Video tracking (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).
    • YouTube embeds get ?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.
    • Vimeo embeds subscribe to 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.
    • Incoming messages are dropped unless event.origin matches the iframe's own origin, and outgoing ones target that origin rather than '*'.
    • Direct video files are rendered with <video> and tracked via timeupdate. If a video is not trackable (unknown host), the button is not locked.
    • The threshold is an honour-system gate, not enforcement. 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.
  • Certificates: generated on course completion (idempotent, unique per enrollment). The public verification page (/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.
  • Two-level feature gates. Certificates and course ratings are each switchable in two places: platform-wide (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.
  • Renaming "Courses" per Space. 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.
  • Notifying course managers. A comment on a lesson notifies everyone holding 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.
  • Why mPDF and not a lighter engine. A certificate carries a person's name, so it has to render whatever script that name is written in. mPDF is the only PHP engine that does glyph-level font fallback (picking a font per text run via autoScriptToLang/autoLangToFont) and Arabic joining plus bidi (useOTL). Verified rendering: Latin, Cyrillic, Greek, Japanese, Chinese (simplified and traditional), Korean, Thai, Devanagari, Hebrew, Arabic, Vietnamese.
    • dompdf was replaced because it cannot do this. It has no glyph fallback, so each script needs its own explicitly registered font, and — the blocker — its CFF/OTF subsetting is broken: Noto Sans CJK embeds but renders as mojibake. Only TrueType outlines work there, and Noto CJK is OTF-only.
    • Cost: 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.
    • Still unsupported: emoji. Colour emoji fonts (CBDT/COLR) have no PDF representation, so emoji render as empty boxes in every PHP engine.
    • mPDF needs a writable scratch directory for its font-metrics cache; the helper points it at @runtime/lms-mpdf and creates it on demand. It must be writable in production or certificate generation fails.
  • Text lessons: instructor HTML is sanitized with 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.
  • Uploads (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).
  • The Students CSV export only includes email for viewers allowed to see it. Core has no per-user "who can see my email" setting — email is only ever shown to the account owner or to a holder of the platform-wide 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.
  • Enrollment capacity: the 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.
  • Progress survives unenrollment on purpose, so re-enrolling keeps prior work. 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.
  • Removing a lesson can complete the course for students already enrolled, so 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.
  • Curriculum edits on a live course warn the instructor. Every mutating action in 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.
  • Quiz questions are updated in place, never replaced. 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.
  • Duplicating a course never shares files with the source. 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.
  • Every 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).
  • The "Duplicate" link shows a Bootstrap spinner while it runs (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).
  • Deleting a course is permanent and relies on DB-level cascade. Every child table's foreign key in 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.
  • Course ratings are one row per (course, user). 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.
  • Lesson comments are deliberately not 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).

Query patterns

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 students
  • Course::ratingSummaryFor($courseIds) — average + count, for the catalogue's stars
  • Course::studentsStartedCountsFor($courseIds) — distinct students with progress, for the delete-course warning
  • $course->completedLessonCountsByUser($userIds) — for instructor rosters

CourseProgressWidget is deliberately presentation-only and takes a percent; it does not look the value up, because doing so was a query per rendered card.

Space scoping

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.

Database integrity checks

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:

  • Soft-deleted users. 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.
  • Attached files that went missing outside the app (thumbnail/lesson PDF 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.

Dependencies

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.

Translations

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.

CategoryCovers
commongeneric UI words used on three or more screens (Add, Cancel, Courses, Title)
attributesmodel attributeLabels() — field names and their hints
optionsenum/choice labels rendered in dropdowns and status badges (Draft, Beginner, Video)
cataloguecourse catalogue filter bar and empty states
coursecourse detail page — enrolling, prerequisites, unenrolling
ratingthe course rating feature
lessonlesson player and its sidebar
quiztaking a quiz and its results
quizAdminquiz/question management
curriculumcurriculum builder — sections, lesson tree, sample content
lessonEditorthe lesson editor panel
courseFormcourse settings form
instructorinstructor dashboard and course header/actions
studentsstudent roster, enrollment and enrollment requests
announcementscourse announcements
progress"My Learning" progress dashboard
certificatecertificates and the public verification page
basemodule name/description, Space menu and sidebar — whatever belongs to no single screen
config · errors · notification · permissionsas 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.

Module Information

Price:
90 €
Latest version release:
1.0.0 - September 27, 2026
Publisher:
Author(s):
Website:
Compatibility:
HumHub 1.19 - 1.19