Courses Module Guide¶
Last reviewed: 2026-06-23
This guide is a reference for maintainers of the Courses module in Project NEXUS. The module ships a full LMS (learning-management) stack: a course catalogue, structured section/lesson content, drip scheduling, quizzes, progress tracking, completion certificates, per-course discussions, cohort management, and paid enrolment via time credits. Credit flows are routed through the battle-tested WalletService — see docs/modules/wallet-exchanges.md for the ledger invariants.
Audience & supported workflows¶
Use this guide when changing course authoring, the enrolment lifecycle, credit charging, progress tracking, quiz grading, certificate issuance, or admin moderation.
Supported workflows:
- Course discovery — members browse, filter (by category, level, keyword), and view course details; anonymous visitors see
visibility=publiccourses only. - Free enrolment — a member self-enrols at no cost when
credit_cost = 0. - Paid enrolment — a member pays the course's
credit_costin time credits; the charge is a direct learner→author transfer routed throughWalletService::transfer(). - Lesson progress — a learner works through lessons in order, with optional drip gating, and the system tracks per-lesson and overall completion percentage.
- Quiz submission — objective questions (MCQ, multi-select, true/false) are auto-graded; subjective questions (short answer, essay) enter a
pending_reviewqueue for instructor grading. - Course completion — finishing all lessons triggers certificate issuance, a gamification XP award, and a completion notification + email to the learner.
- Certificate download — a completed learner retrieves a printable HTML certificate with a unique serial for verification.
- Learner review — enrolled learners rate a course 1–5 stars; the aggregate is cached on the
coursesrow. - Instructor authoring — an authorised user builds a course (sections → lessons → quizzes), publishes it, and manages their roster and analytics.
- Admin moderation — an admin reviews pending courses, approves/rejects/flags them, manages instructor grants, and views tenant-level analytics.
- Cohort delivery — a course-paced variant where learners are assigned to named cohorts with start/end dates and optional capacity limits.
- Group-linked courses —
visibility=groupcourses are visible only to members of linked groups.
Tenant & feature-gate rules¶
Feature flag: courses (default OFF). Every Courses module endpoint and every controller method calls ensureCoursesFeature() as its first step; requests return FEATURE_DISABLED (HTTP 403) when the flag is not set. The flag is resolved via TenantContext::hasFeature('courses').
Tenant scoping is enforced automatically by the HasTenantScope trait on the Course, CourseEnrollment, CourseCertificate, and related Eloquent models. Every service call that bypasses these scopes (e.g. Course::withoutGlobalScopes() in CourseEnrollmentService::tenantIdForCourse()) does so explicitly and immediately re-establishes the correct tenant via TenantContext::runForTenant().
Additional per-tenant settings (stored in tenants.configuration as JSON):
| Setting key | Default | Effect |
|---|---|---|
courses.moderation_enabled |
false |
When true, new course publishes stay pending until an admin approves via the moderation endpoint. |
courses.allow_member_authoring |
true |
When false, only users with an explicit instructor grant or admin role may create courses. |
Key code & data locations¶
Routes are defined in routes/api.php around line 1048. Do not copy the full endpoint table here — read the route file for the live list. Primary entry points:
| Concern | Route prefix | Controller |
|---|---|---|
| Course catalogue & authoring | /v2/courses |
App\Http\Controllers\Api\CourseController |
| Enrolment, progress, certificates, reviews | /v2/courses/{id}/*, /v2/me/courses |
App\Http\Controllers\Api\CourseEnrollmentController |
| Section & lesson builder | /v2/courses/{id}/sections, /v2/courses/{id}/lessons |
App\Http\Controllers\Api\CourseContentController |
| Quizzes & grading | /v2/courses/quizzes/*, /v2/courses/{id}/grading |
App\Http\Controllers\Api\CourseQuizController |
| Cohort management | /v2/courses/{id}/cohorts |
App\Http\Controllers\Api\CourseCohortController |
| Per-lesson discussions | /v2/courses/{courseId}/lessons/{lessonId}/discussions |
App\Http\Controllers\Api\CourseDiscussionController |
| Group links | /v2/courses/{id}/groups, /v2/groups/{id}/courses |
App\Http\Controllers\Api\CourseGroupController |
| Admin moderation & analytics | /v2/admin/courses/* |
App\Http\Controllers\Api\AdminCourseController |
The InteractsWithCourses trait (app/Http/Controllers/Api/Concerns/InteractsWithCourses.php) provides the feature gate check, 404 lookup, authoring authorisation, and audience visibility logic used by all controllers above.
Services:
app/Services/CourseService.php— tenant-scoped course CRUD, browse/search, publish/unpublish lifecycle. Authorship and moderation state are set server-side only and are not mass-assignable.app/Services/CourseEnrollmentService.php— enrolment creation and idempotency, drop/reactivate, enrolment roster.app/Services/CourseCreditService.php— time-credit charge for paid enrolment; routes the learner→author transfer throughWalletService::transfer().app/Services/CourseLessonService.php— lesson CRUD and drip availability calculation.app/Services/CourseSectionService.php— section CRUD.app/Services/CourseProgressService.php— lesson-completion tracking, progress percentage recomputation, course-completion side effects.app/Services/CourseQuizService.php— quiz delivery (without answer keys), attempt submission, auto-grading, instructor grading, max-attempts enforcement.app/Services/CourseCertificateService.php— idempotent certificate issuance with a uniqueCRS-*serial, printable HTML generation.app/Services/CoursePrerequisiteService.php— prerequisite course resolution and per-learner completion state.app/Services/CourseCohortService.php— cohort CRUD for cohort-paced delivery.app/Services/CourseNotificationService.php— enrolment and completion notifications (in-app + email), locale-wrapped per recipient.app/Services/CourseInstructorService.php— instructor capability grants.app/Services/CourseDiscussionService.php— per-lesson discussion threads.app/Services/CourseCategoryService.php— category CRUD for catalogue taxonomy.app/Services/CourseGroupService.php— group-to-course link management.
Models and tables:
| Model | Table |
|---|---|
App\Models\Course |
courses |
App\Models\CourseCategory |
course_categories |
App\Models\CourseSection |
course_sections |
App\Models\CourseLesson |
course_lessons |
App\Models\CourseEnrollment |
course_enrollments |
App\Models\CourseLessonProgress |
course_lesson_progress |
App\Models\CourseQuiz |
course_quizzes |
App\Models\CourseQuestion |
course_questions |
App\Models\CourseQuizAttempt |
course_quiz_attempts |
App\Models\CourseCertificate |
course_certificates |
App\Models\CourseReview |
course_reviews |
App\Models\CourseDiscussion |
course_discussions |
App\Models\CourseCohort |
course_cohorts |
App\Models\CourseInstructor |
course_instructors |
App\Models\CourseGroupLink |
course_group_links |
Migrations: database/migrations/2026_05_29_000001_create_courses_core_tables.php through …000004_create_course_social_tables.php, plus 2026_06_05_000000_add_course_certificate_unique_index.php.
Course content structure¶
A course has the following hierarchy:
Course
└── CourseSection (ordered by position)
└── CourseLesson (ordered by position)
└── CourseQuiz (optional; one per lesson or standalone)
└── CourseQuestion (mcq / multi / truefalse / short / essay)
A lesson can be one of five content types: video, text, pdf, embed, or quiz. The body field carries rich text; video_url, attachment_url, and embed_url carry media links (validated as http/https URLs). Section assignment is optional — lessons belong to a course with or without an enclosing section, and CourseContentController verifies that a supplied section_id belongs to the same course before saving it.
Visibility and authoring rules¶
Catalogue visibility¶
The visibility enum has three values:
| Value | Who can see it |
|---|---|
public |
Anyone including anonymous visitors |
members |
Authenticated members only |
group |
Members of a group linked to the course via course_group_links |
A course is only surfaced in browse results and reachable via show when status = published AND moderation_status = approved. Instructors and admins can see their own draft/pending courses regardless of status through canManageCourseAsUser().
Attempting to view a course outside the caller's audience returns a RESOURCE_NOT_FOUND (HTTP 404) to avoid leaking course existence.
Who can author¶
By default (courses.allow_member_authoring = true) any authenticated member may create a course. A tenant may restrict authoring to explicit instructor grants plus admins by setting that option to false. Either way, editing, publishing, or deleting a course is restricted to its original author or an admin.
Publication and moderation¶
New courses are created as status=draft, moderation_status=pending. CourseService::publish() sets status=published. If courses.moderation_enabled is true for the tenant, the course stays at moderation_status=pending until an admin calls the moderation endpoint. If moderation is disabled, the first publish auto-approves the course and stamps published_at.
On first approval, CourseService::publish() posts a feed activity for the author (guarded so a feed failure never blocks publishing).
Instructor grants¶
When courses.allow_member_authoring = false, a member must be granted the instructor capability by an admin via POST /v2/admin/courses/instructors. Grants are idempotent (one row per tenant+user in course_instructors). Revocation deletes the row. Admin users bypass the grant check regardless of this setting.
Enrolment lifecycle¶
Not enrolled
│
▼ POST /v2/courses/{id}/enroll
active ─── (all lessons completed) ──▶ completed
│
▼ DELETE /v2/courses/{id}/enroll
dropped ─── (re-enrol) ──▶ active (no second charge)
Idempotency: CourseEnrollmentService::enroll() returns the existing enrollment row when a learner re-enrols while already active or completed. The unique index (tenant_id, course_id, user_id) on course_enrollments enforces one row per learner+course at the database layer.
Prerequisites: before enrolment proceeds, CoursePrerequisiteService::unmetIds() checks that the learner has a completed enrollment in every course listed in the courses.prerequisites JSON array. Unmet prerequisites return PREREQUISITES_NOT_MET (HTTP 422).
Cohort assignment: an optional cohort_id may be supplied at enrolment. The service validates that it belongs to the same course before accepting it, preventing roster pollution from an arbitrary or cross-course cohort id.
Paid enrolment & credit flow¶
When credit_cost > 0, calling POST /v2/courses/{id}/enroll triggers CourseEnrollmentService::enrollWithPayment():
- The course row is re-read inside a
DB::transactionwithlockForUpdate()socredit_costandauthor_user_idare the freshest values, not a stale cached instance. CourseCreditService::chargeEnrollment()callsWalletService::transfer()to movecredit_costcredits from the learner to the course author.- If the charge succeeds,
enrollment.credits_paidis updated and the enrollment is saved. - If the charge fails (insufficient credits, inactive author, etc.) a
RuntimeExceptionis thrown, the transaction rolls back (no enrollment row is created), and the controller returnsINSUFFICIENT_CREDITS(HTTP 422).
Special cases:
- Zero cost:
credit_cost = 0is treated as free — no transfer is made. - Author self-enrolment: a user enrolling in their own course is never charged regardless of
credit_cost. - Re-enrolment after dropping: a dropped learner has already paid once.
enrollWithPayment()detects thedroppedrow and calls the freeenroll()path, preserving the "charge exactly once" contract. - Double-submit: the outer idempotency check (
isEnrolledshort-circuit in the controller) catches a concurrent second call before the transaction runs.
The transfer uses WalletService's row-locked, atomic path. See docs/modules/wallet-exchanges.md for the full ledger invariants.
Lesson progress and drip scheduling¶
CourseProgressService::completeLesson() calls CourseLessonProgress::updateOrCreate() (idempotent), then recomputes enrollment.progress_percent as (completed_lessons / total_lessons) * 100. When the ratio reaches 100%, the enrollment transitions to completed and onCourseCompleted() fires.
Drip scheduling (drip_type on course_lessons):
| Value | Behaviour |
|---|---|
none |
Lesson available immediately (default). |
days_after_enroll |
Unlocks drip_offset_days days after the learner's enrolled_at. |
fixed_date |
Unlocks on drip_date regardless of when the learner enrolled. |
CourseLessonService::availability() computes the {available: bool, unlock_at: ?ISO8601} response. A locked lesson returns LESSON_LOCKED (HTTP 403) if the learner tries to mark it complete.
Quizzes¶
Question types:
| Type | Auto-graded? |
|---|---|
mcq (single-answer) |
Yes |
multi (multi-select) |
Yes |
truefalse |
Yes |
short |
No — enters pending_review queue |
essay |
No — enters pending_review queue |
CourseQuizService::forLearner() strips correct and explanation fields before delivering questions to the learner, so answer keys are never exposed client-side. CourseQuestion.$hidden enforces this at the model layer as well.
Attempt limits: course_quizzes.max_attempts (0 = unlimited). The service locks the enrollment row inside a DB::transaction before checking and recording the attempt count, preventing a race between two concurrent submission requests from the same learner. Exceeding the limit throws MaxAttemptsExceededException.
Auto-grading: for objective questions, isCorrect() compares sorted arrays of answer ids (handling both MCQ and multi-select), awards the question's points value, and computes score_percent. A quiz is passed = true when score_percent >= pass_mark_percent AND no subjective questions are present (a quiz with any short/essay question gets grading_status = pending_review and passed = false until manually graded).
Instructor grading queue: GET /v2/courses/{courseId}/grading returns attempts at grading_status = pending_review, including question prompts and the learner's answers but never the answer key. POST /v2/courses/attempts/{attemptId}/grade applies an instructor score and sets grading_status = graded.
Course completion side effects¶
When the last lesson is marked complete, CourseProgressService::onCourseCompleted() fires the following — each step is individually try/catch-guarded so a failure in one never blocks the learner's progression record:
- Increments
courses.completion_count. - Issues (or returns the existing) completion certificate via
CourseCertificateService::issue(). - Sends a completion notification + email to the learner in their
preferred_languageviaCourseNotificationService::completed(). - Awards 50 XP and the
course_graduatebadge viaGamificationService.
Certificates¶
Certificates are issued by CourseCertificateService::issue(), which is idempotent (one certificate per (tenant_id, course_id, user_id) enforced by a unique index). The serial format is CRS- followed by 12 random upper-case alphanumeric characters.
GET /v2/courses/{id}/certificate returns both the certificate record (serial, issued_at) and a self-contained html string — an inline-styled HTML document suitable for browser printing to PDF. All human-readable strings in the HTML are rendered via __('emails_misc.course_certificate.*') keys, so they honour the active locale.
Learner reviews¶
Enrolled and completed learners may leave one review per course (POST /v2/courses/{id}/reviews). Dropped learners cannot review. Each submission uses updateOrCreate so a learner can update their review. After each upsert, CourseEnrollmentController::recomputeCourseRating() recalculates rating_avg and rating_count on the courses row from all status=approved reviews.
Per-lesson discussions¶
course_discussions carries threaded posts (via parent_id) attached to a lesson within a course. Admins may hide individual posts via POST /v2/admin/courses/discussions/{id}/hide. Authors can delete their own posts; admins can delete any.
Admin surfaces¶
| Endpoint | What it does |
|---|---|
GET /v2/admin/courses |
List all courses (filterable by moderation_status). |
POST /v2/admin/courses/{id}/moderate |
Approve / reject / flag a course. Rejecting forces status=draft. |
GET /v2/admin/courses/analytics |
Tenant-level totals (published courses, pending, enrollments, completions, instructors). |
GET /v2/admin/courses/instructors |
List all instructor grants. |
POST /v2/admin/courses/instructors |
Grant instructor capability to a user. |
DELETE /v2/admin/courses/instructors/{userId} |
Revoke instructor capability. |
POST /v2/admin/courses/categories |
Create a category. |
PUT /v2/admin/courses/categories/{id} |
Update a category. |
DELETE /v2/admin/courses/categories/{id} |
Delete a category. |
POST /v2/admin/courses/discussions/{id}/hide |
Moderate a discussion post. |
Per-course analytics (GET /v2/courses/{id}/analytics, owner or admin only) includes enrollment counts by status, completion rate, average progress, average quiz score, and a per-lesson drop-off curve.
Security & privacy invariants¶
- The
coursesfeature must be enabled before any Courses endpoint runs — checked viaensureCoursesFeature(). author_user_id,status,moderation_status, andpublished_atare not mass-assignable on theCoursemodel. They are set explicitly by the service layer to prevent authorship spoofing, self-publishing, or moderation bypass.- Course visibility is enforced before returning any course data; private, draft, rejected, or group-only courses return 404 to callers outside the intended audience.
- Quiz answer keys (
correct,explanation) are not present inforLearner()output.CourseQuestion.$hiddenis a second layer of protection. - The paid enrolment transaction (charge + row creation) runs inside a single
DB::transactionwith the course row locked, so no enrollment is created unless the credit movement commits. - Every
UPDATE/DELETEoncourse_enrollments,course_lesson_progress, andcourse_quiz_attemptsmust reference the correctenrollment_idoruser_id— avoid bypassing tenant scope on these tables. - Discussion moderation (hide) requires admin access; delete requires either authorship or admin access.
Failure modes & recovery¶
| Failure | How it is handled |
|---|---|
| Feature disabled | All endpoints return HTTP 403 FEATURE_DISABLED. |
| Insufficient credits (paid enrolment) | WalletService::transfer() throws inside the DB transaction; the transaction rolls back; no enrollment row is created; controller returns HTTP 422 INSUFFICIENT_CREDITS. |
| Author self-enrolment | CourseCreditService skips the charge and returns charged=false; enrolment proceeds free. |
| Dropped learner re-enrolls | Detected before the charge; the existing dropped row is reactivated without a second credit transfer. |
| Concurrent double-enrolment | Unique index (tenant_id, course_id, user_id) on course_enrollments rejects the duplicate; enrollWithPayment() returns the existing row. |
| Quiz max-attempts exceeded | The attempt count is checked inside a row-locked transaction to prevent races; throws MaxAttemptsExceededException. |
| Concurrent quiz submission | The enrollment row is locked inside the DB::transaction for the attempt; only one write wins. |
| Certificate issuance race | Idempotency: the unique index rejects the duplicate and issue() fetches and returns the winning row. |
| Feed post failure on publish | Wrapped in try/catch; logs a warning; publish is not blocked. |
| Notification / email failure | Wrapped in try/catch throughout; logged; never blocks enrolment, completion, or publish. |
| Gamification failure on completion | Guarded individually; a GamificationService outage does not prevent the enrollment from reaching completed. |
| Lesson drip gate | A learner trying to complete a locked lesson receives HTTP 403 LESSON_LOCKED. The unlock time is included in the progress response (availability[].unlock_at). |
| Section id from another course | CourseLessonService validates the section_id belongs to the same course and silently sets it to null if not. |
| Moderation pending | A published course stays invisible in the public catalogue (no 404, just not returned by browse) until moderation_status = approved. The instructor can still view it via GET /v2/courses/mine. |
Test commands & key regression tests¶
Run the relevant suites (run one suite at a time):
vendor/bin/phpunit tests/Laravel/Feature/Courses/ --colors=always
vendor/bin/phpunit tests/Laravel/Feature/Controllers/CourseControllerTest.php --colors=always
vendor/bin/phpunit tests/Laravel/Unit/Services/CourseLessonServiceTest.php --colors=always
vendor/bin/phpunit tests/Laravel/Feature/GovukAlpha/CoursesFiltersQuizParityTest.php --colors=always
Key regression tests:
| Test | What it locks down |
|---|---|
tests/Laravel/Feature/Courses/CourseCreditTest.php |
Paid enrolment transfers credits learner→author; insufficient balance is blocked; free course is not charged; enrolling twice does not double-charge; credits_paid is recorded on the enrollment. |
tests/Laravel/Feature/Courses/CourseProgressAndQuizTest.php |
Enrolment idempotency; drop/reactivate; completing all lessons transitions status to completed; MCQ auto-grading (correct answer = pass); max-attempts enforcement (race-safe); section-id cross-course injection rejected; moderation flag respected; tenant isolation (course invisible under another tenant). |
tests/Laravel/Unit/Services/CourseLessonServiceTest.php |
Drip availability calculation for none, days_after_enroll, and fixed_date types; media URL validation. |
tests/Laravel/Feature/GovukAlpha/CoursesFiltersQuizParityTest.php |
Accessible-frontend parity for catalogue filters and quiz flow. |
tests/Laravel/Feature/GovukAlpha/CoursesPrereqCertParityTest.php |
Accessible-frontend parity for prerequisites and certificate. |
tests/Laravel/Feature/GovukAlpha/CoursesReviewsParityTest.php |
Accessible-frontend parity for reviews. |
tests/Laravel/Feature/Controllers/CourseControllerTest.php |
HTTP-level feature gate, authoring auth, and CRUD responses. |
Related references¶
- docs/modules/wallet-exchanges.md — ledger invariants, idempotency guard, and money column precision; the paid enrolment path flows through
WalletService::transfer(). - docs/MODULES.md — full module map and writing checklist.
- docs/ARCHITECTURE.md — runtime boundaries.
routes/api.php— authoritative endpoint list (do not duplicate here).