Skip to content

Shared Lessons: Technical Specification

GitHub issue #21169

Overview

Shared Lessons allow the same Lesson to be reused across multiple Courses. A Lesson is "shared" when it has no parent Course (course__lessons.course_id IS NULL). Currently used for Lesson Zero (L0) and Final Lesson (LF).

Originally Shared Lessons feature was created to make L0 and LF skippable, but the potential of this feature is bigger: with minor changes, we can make any lesson shared and e.g., get benefits of it when we update existing Courses.

Terminology

  • Shared Lesson — A Lesson with course_id = NULL. The only entity that is truly "shared".
  • Core Lesson — A regular Lesson with course_id IS NOT NULL — belongs to a specific Course.
  • Shared Answer — A QuizAnswer with course_enrollment_id = NULL. Created when a Member correctly answers an MCQ in a Shared Lesson.
  • Enrollment-scoped Answer — A QuizAnswer with course_enrollment_id = <id>. The default for all OEQs and incorrect MCQs.
  • Shared Quiz Points — Cached {quiz_id: score} map on CourseEnrollmentProgress.shared_quiz_points. Tracks which shared answers have been synced to an enrollment.

Important: Only Lessons can be shared. When you see terms like "shared score", "shared quizzes", or "shared lesson items", they refer to content/progress within a Shared Lesson. Quizzes and LessonItems cannot be shared individually.

Product Needs

IxDF wants to have introduction and final lesson materials, the same in all courses. But Members don't want to spend their time on the same materials in different courses.

To solve this, we decided to use Skippable/Shared Lessons and share the state of such Lessons across different courses. If a Member posted an incorrect answer for a Shared Lesson — allow the Member to submit another answer within another Course, so they can improve their score. That's why only correct answers are shared.

Share Lesson Progress only when a Member visited a shared Lesson. This decision is made for simplicity and can be revisited in the future (e.g. update all suitable Course Enrollments)

We need to eventually migrate existing Course Enrollments to use Shared Lessons.

Database Schema

Affected Tables

TableColumnChange
course__coursesintroduction_lesson_idNullable FK to course__lessons. Points to the shared intro Lesson (L0) for this Course.
course__coursesfinal_lesson_idNullable FK to course__lessons. Points to the shared final Lesson (LF) for this Course.
course__lessonscourse_idNullable. NULL = shared Lesson (no parent Course).
course__quiz_answerscourse_enrollment_idNullable. NULL = shared answer (not tied to any enrollment).
course__course_enrollment_progressshared_quiz_pointsJSON column. {quiz_id: score} cache. Needed to track what is synchronised.
course__course_schedulescourse_idFK to course__courses.
course__lesson_scheduleslesson_numberAdd integer to replace course__lessons.lesson_number.
course__lessonslesson_numberRename to template_lesson_number and make nullable.
course__domain_eventsNew table. Stores LessonItemFirstTimeVisited and other events.

Migrations

  • 2025_08_07_152133_add_lesson_number_to_course__lesson_schedules_table.php – Adds course__lesson_schedules.shared_quiz_points int column.
  • 2025_10_17_090233_add_shared_quiz_points_to_course_enrollment_progress_table.php — Adds shared_quiz_points JSON column.
  • 2025_11_06_141816_make_course_enrollment_nullable.php — Makes course_enrollment_id nullable on course__quiz_answers.

How Shared Lessons Are Linked to Courses

A Shared Lesson has course_id = NULL, so it doesn't belong to any Course via the standard course__lessons.course_id FK. Instead, the course__courses table has two nullable columns that point to shared Lessons:

  • introduction_lesson_id — the shared intro Lesson (L0)
  • final_lesson_id — the shared final Lesson (LF)

A single shared Lesson (e.g., one L0 row) can be referenced by many Courses.

How Shared Lessons Get Scheduled

When a CourseSchedule is created, CourseSchedule::makeLessonSchedules() builds the full lesson sequence by calling Course::getSharedAndCoreLessons(), which assembles the list in order:

  1. introductionLesson (L0, if set) — gets lesson_number = 0
  2. coreLessons (regular lessons with course_id = this course) — get lesson_number = 1, 2, 3, ...
  3. finalLesson (LF, if set) — gets lesson_number = N (last)

Each Lesson gets a LessonSchedule row with a lesson_number assigned sequentially. This is why the Shared Lesson's lesson_number is dynamic per Course — a Course with 5 core lessons has LF at number 6, while a Course with 10 core lessons has LF at number 11.

Note: Lesson::$template_lesson_number on the model itself is a template-level ordering. The real lesson number comes from LessonSchedule::$lesson_number.

Challenges

  1. Not always possible to get Course from Lesson: We had a lot of code like $quiz->lessonItem->getUrl(). Because Full Lesson Numbers require Course slug, Lesson Number, and Lesson Item number — and Lesson::$course_id is nullable — we can't generate URLs without having CourseSchedule/LessonSchedule in the context.
  2. Tracking synchronized answers: We need to track somewhere which answers are already synchronized and which are not.
  3. Discussable Lesson Items: Lesson Items may have attached Conversations. It's not possible to get a URL to a discussion like $discussionMessage->discussion->discussable->getDiscussionMessageUrl(...) as we need to know Course slug and Lesson number.

How Answers Are Created

MCQ (Multiple-Choice Questions)

AnswerMCQQuizAction decides how to store the answer:

text
IF answer is correct AND lesson is shared:
    → course_enrollment_id = NULL (shared answer)
    → score = quiz.score
ELSE:
    → course_enrollment_id = enrollment.id (enrollment-scoped)
    → score = 0 (incorrect) or quiz.score (correct, non-shared lesson)

After saving, the AnswerQuizAction base class caches shared points immediately for the current enrollment via enrollmentProgress->mergeSharedQuizPoints().

OEQ (Open-Ended Questions)

AnswerOEQQuizAction always creates enrollment-scoped answers (course_enrollment_id = enrollment.id), even for shared Lessons. This is a temporary limitation, but a safe one — by convention, we should not use OEQs in Shared Lessons.

Synchronization

When It Runs

Synchronization is triggered via an AJAX call when a Member visits a Shared Lesson page.

  • Route: POST courses.enrollments.syncSharedLesson

It synchronizes only the Lesson a Member visited, and only updates the current Course Enrollment.

Lesson and Lesson Item state synchronization

For performance reasons, CourseEnrollment model has a separate CourseEnrollmentProgress model that stores a lot of CourseEnrollment-related data to avoid running heavy DB queries to recalculate all the data. Such cache also includes:

  • state of Lessons (completed/not completed)
  • state of Lesson Items (completed/not completed)

While for Mandatory Lessons and Lesson Items, it's straightforward what is completed (a Member should submit answers to all quizzes), for Optional Lesson Items it's different - it's enough to visit such Lesson Items. So, the app needs a place to store somewhere which Optional Lesson Items are visited, and only then it can synchronize their state. For these reasons, we introduced course__domain_events DB table that currently stores 2 event types:

  • \App\Modules\Course\Events\LessonItemFirstTimeVisited
  • \App\Modules\Course\Events\SharedLessonStateSynchronized

LessonItemFirstTimeVisited is used to find visited shared Lesson Items and share their state.

Why course__domain_events instead of Laravel's event system?

This is the first step toward Event Sourcing (ES) for the Course module (see #26739). The idea to use ES for courses has been around for a long time, but Shared Lessons was the first case where we needed to persistently store a domain event (LessonItemFirstTimeVisited) as a source of truth rather than just dispatching it ephemerally. There are no deadlines or strict plans to fully switch to ES, but by storing domain events now, we can migrate to it at any point in the future.

Algorithm

text
1. Validate lesson is shared (course_id IS NULL)
2. Capture enrollment state before sync (earned_score, completed items/lessons)
3. Query all shared QuizAnswers for the lesson's quizzes (member-scoped)
4. In a DB transaction:
   a. For each shared answer:
      - Skip if already cached in shared_quiz_points (idempotent)
      - Skip if enrollment-scoped answer exists (precedence rule)
      - Cache points: enrollmentProgress.mergeSharedQuizPoints(quiz_id, score)
      - Accumulate total points
   b. Sync visited optional LessonItems from domain events
   c. If new points or items synced:
      - enrollment.earned_score += totalPoints
      - Save enrollment and progress
5. Run CompleteLessonItemWhenReadyAction for each LessonItem
6. Run CompleteLessonWhenReadyAction for the Lesson
7. Calculate diff (newly completed lessons/items)
8. Resolve next lesson navigation URL
9. Dispatch SharedLessonStateSynchronized domain event
10. Return SharedLessonSyncResult

Answer Precedence

text
Enrollment-scoped answer > Shared answer > No answer

If a Member answered a quiz directly within a Course Enrollment (even incorrectly), that answer takes precedence. The shared answer is ignored for that quiz in that enrollment.

Can a Member Re-answer a Shared Quiz?

No. The QuizPolicy::answerQuiz() checks two things in order:

  1. Does an enrollment-scoped answer exist for this quiz + enrollment? If yes — locked.
  2. Does a shared answer exist for this quiz? If yes — locked.

So if a Member answered correctly in Course A (shared answer created), the quiz is locked in all Courses — they cannot submit another answer in Course B. The shared answer will be synced instead when they visit the Lesson.

If a Member answered incorrectly in Course A (enrollment-scoped answer created), the quiz is still open in Course B (no enrollment-scoped answer for B, no shared answer exists). This gives the Member a chance to improve their score in the next Course.

API Response

json
{
  "synced": true,
  "isSharedLessonFullyCompleted": true,
  "diff": {
    "earnedPointsAdded": 24,
    "potentialPointsAdded": 0,
    "scoreBeforeSync": 100,
    "scoreAfterSync": 124,
    "newlyCompletedLessonIds": [1785],
    "newlyCompletedLessonItemIds": [15839, 15840]
  },
  "nextLesson": {
    "fullNumber": "1.1",
    "url": "/courses/some-course/lessons/1.1"
  }
}

Such detailed data provides all info needed to make UI more dynamic.

Frontend

When a Member opens a Shared Lesson page, the frontend automatically calls the sync endpoint via AJAX. If new points or completions are returned, the UI updates the achievement panel (score, progress bar) and the lesson navigator (completion indicators) without a page reload. The entry point is resources/js/pages/courses/sharedLessonSync.js.

Completion Logic

LessonItem Completion (LessonItemIsCompletedQuery)

  • Has quizzes: Complete when all quizzes are answered (enrollment-scoped or shared).
  • No quizzes (optional): Complete when visited (tracked via LessonItemFirstTimeVisited domain event).

Lesson Completion (LessonIsCompletedQuery)

  • Complete when all its LessonItems are complete.

Optional LessonItem Sync

The synchronizer also handles optional (quiz-less) LessonItems by querying course__domain_events for LessonItemFirstTimeVisited events and marking them as visited/completed in the enrollment progress.

Domain Events

EventWhenStored in
SharedLessonStateSynchronizedAfter a successful synccourse__domain_events
LessonItemFirstTimeVisitedWhen a Member visits a LessonItem for the first timecourse__domain_events
LessonItemCompletedWhen a LessonItem becomes completeDispatched via Laravel events. Not stored.
LessonCompletedWhen a Lesson becomes completeDispatched via Laravel events. Not stored.

How LessonItemFirstTimeVisited is created

The chain is: LessonItemShowController dispatches LessonItemVisited on every page view → LessonItemEventSubscriber::markLessonItemAsVisited() checks if already visited → if first visit, dispatches LessonItemFirstTimeVisitedDomainEventRecorder::record() (queued listener) writes it to course__domain_events.

Key Classes

ClassPath
SyncSharedLessonControllerapp/Modules/Course/Http/Controllers/SyncSharedLessonController.php
SharedLessonPointsSynchronizerapp/Modules/Course/Services/SharedLessonPointsSynchronizer.php
SharedLessonSyncResult (DTO)app/Modules/Course/Dto/SharedLessonSyncResult.php
SharedLessonStateSynchronized (event)app/Modules/Course/Events/SharedLessonStateSynchronized.php

Templates vs. Schedules

Course and Lesson are templates — they define the structure of what a course looks like. CourseSchedule and LessonSchedule are instances — they store the actual course structure that Members are enrolled in.

When a CourseSchedule is created, the app reads the Course template (its core lessons + shared intro/final lessons) and generates LessonSchedule rows. From that point on, the CourseSchedule is self-contained. Changes to the Course template (adding/removing lessons, detaching a shared lesson) only affect future CourseSchedules.

What happens if L0 is detached from a Course? Existing CourseSchedules (and their enrollments) are not affected — they already have their own LessonSchedule rows pointing to that Lesson. Synced shared points remain intact. Only new CourseSchedules created after the detachment will lack L0.

Migration to Shared Lessons

  1. [x] Create new shared Lessons (detached) on Nova, polish their content
  2. [x] Attach Shared Lessons to Courses to use Shared Lessons for new Course Schedules. Existing/old Enrollments will remain the same
  3. [x] Migrate existing active Course Enrollments to use Shared Lessons, even if Member will lose score (Slack thread)
    1. [x] When doing it, find quiz clones from custom Introduction and Final Lessons inside Shared versions and remap QuizAnswers to use IDs of shared ones. For example, if a Course had its own L0 with Quiz #100 and the Shared L0 has the equivalent Quiz #200, remap answers from #100 to #200.
  4. [x] Update code to use LessonSchedule::$lesson_number instead of Lesson::$template_lesson_number (incl. building full lesson URLs)

By the end of migration, old active Course Enrollments will lose some points, e.g. Enrollment#1200799 — this is expected and acceptable.

Architectural results

See Templates vs. Schedules — Shared Lessons drove the shift from Course/Lesson as the source of truth to CourseSchedule/LessonSchedule as self-contained instances.

Observability

The primary tool for inspecting shared points is the Nova CourseEnrollment detail page (/admin/nova/resources/course-enrollments/{id}, e.g. Enrollment#1215206). The "Shared Points Breakdown" section shows:

  • How many Shared Lessons the CourseSchedule contains
  • Per-quiz status for each Shared Lesson, with one of the following statuses (see the "Shared Lesson State Legend" section on Nova for more details):
    • Synchronized
    • Not synchronized yet
    • Wrong answer in this Enrollment
    • No shared answer
  • Total shared points synchronized

There is no automated alerting for sync failures. The sync endpoint returns "synced": false only when there is nothing to sync (not a shared lesson, or no new points). Actual errors throw exceptions and return HTTP 500 — the frontend distinguishes the two by HTTP status code. Exceptions surface in storage/logs/laravel.log and BugSnag.

Known Limitations & Future Work

  • Sync is visit-based only. There is no background job or scheduled command that syncs shared points across all enrollments. A Member must visit the Shared Lesson to trigger sync.
  • OEQs are never shared. They always stay enrollment-scoped because they require manual grading.
  • Admin workaround: To manually sync for a Member, impersonate them and visit the Shared Lesson in the affected Course.

FAQ

  1. What happens when quizzes are added/removed from a Shared Lesson? — Grey area (so far), as it requires discussion (most likely we need to recalculate the score, see Modifying Published Courses)