Appearance
Shared Lessons: Technical Specification
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
Lessonwithcourse_id = NULL. The only entity that is truly "shared". - Core Lesson — A regular
Lessonwithcourse_id IS NOT NULL— belongs to a specific Course. - Shared Answer — A
QuizAnswerwithcourse_enrollment_id = NULL. Created when a Member correctly answers an MCQ in a Shared Lesson. - Enrollment-scoped Answer — A
QuizAnswerwithcourse_enrollment_id = <id>. The default for all OEQs and incorrect MCQs. - Shared Quiz Points — Cached
{quiz_id: score}map onCourseEnrollmentProgress.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
| Table | Column | Change |
|---|---|---|
course__courses | introduction_lesson_id | Nullable FK to course__lessons. Points to the shared intro Lesson (L0) for this Course. |
course__courses | final_lesson_id | Nullable FK to course__lessons. Points to the shared final Lesson (LF) for this Course. |
course__lessons | course_id | Nullable. NULL = shared Lesson (no parent Course). |
course__quiz_answers | course_enrollment_id | Nullable. NULL = shared answer (not tied to any enrollment). |
course__course_enrollment_progress | shared_quiz_points | JSON column. {quiz_id: score} cache. Needed to track what is synchronised. |
course__course_schedules | course_id | FK to course__courses. |
course__lesson_schedules | lesson_number | Add integer to replace course__lessons.lesson_number. |
course__lessons | lesson_number | Rename to template_lesson_number and make nullable. |
course__domain_events | — | New table. Stores LessonItemFirstTimeVisited and other events. |
Migrations
2025_08_07_152133_add_lesson_number_to_course__lesson_schedules_table.php– Addscourse__lesson_schedules.shared_quiz_pointsint column.2025_10_17_090233_add_shared_quiz_points_to_course_enrollment_progress_table.php— Addsshared_quiz_pointsJSON column.2025_11_06_141816_make_course_enrollment_nullable.php— Makescourse_enrollment_idnullable oncourse__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:
introductionLesson(L0, if set) — getslesson_number = 0coreLessons(regular lessons withcourse_id = this course) — getlesson_number = 1, 2, 3, ...finalLesson(LF, if set) — getslesson_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_numberon the model itself is a template-level ordering. The real lesson number comes fromLessonSchedule::$lesson_number.
Challenges
- 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 — andLesson::$course_idis nullable — we can't generate URLs without havingCourseSchedule/LessonSchedulein the context. - Tracking synchronized answers: We need to track somewhere which answers are already synchronized and which are not.
- 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 SharedLessonSyncResultAnswer Precedence
text
Enrollment-scoped answer > Shared answer > No answerIf 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:
- Does an enrollment-scoped answer exist for this quiz + enrollment? If yes — locked.
- 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
LessonItemFirstTimeVisiteddomain 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
| Event | When | Stored in |
|---|---|---|
SharedLessonStateSynchronized | After a successful sync | course__domain_events |
LessonItemFirstTimeVisited | When a Member visits a LessonItem for the first time | course__domain_events |
LessonItemCompleted | When a LessonItem becomes complete | Dispatched via Laravel events. Not stored. |
LessonCompleted | When a Lesson becomes complete | Dispatched 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 LessonItemFirstTimeVisited → DomainEventRecorder::record() (queued listener) writes it to course__domain_events.
Key Classes
| Class | Path |
|---|---|
SyncSharedLessonController | app/Modules/Course/Http/Controllers/SyncSharedLessonController.php |
SharedLessonPointsSynchronizer | app/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
- [x] Create new shared Lessons (detached) on Nova, polish their content
- [x] Attach Shared Lessons to Courses to use Shared Lessons for new Course Schedules. Existing/old Enrollments will remain the same
- [x] Migrate existing active Course Enrollments to use Shared Lessons, even if Member will lose score (Slack thread)
- [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.
- [x] Update code to use
LessonSchedule::$lesson_numberinstead ofLesson::$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
- 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)