Section Modules Enrichment
Three-phase performance and data enrichment for GET /api/v1/courses/{courseId}/sections/{sectionId}/modules — adds module names, eliminates N+1 completion queries, and caches structure data.
Overview (5W)
Three progressive enhancements to the section modules endpoint:
- Phase 1 — Adds a
namefield (string|null) to every module in the hierarchy response by eager-loading the correct plugin-specific relation (assign,quiz,lesson,page,label,url). - Phase 2 — Eliminates the N+1 problem by pre-fetching all completion records for a section in one query and passing them via
AvailabilityContext::$completionMap. - Phase 3 — Wraps the module structure query in
Cache::remember()with a 1-hour TTL, shared across all students.
Triggered on every authenticated GET /api/v1/courses/{courseId}/sections/{sectionId}/modules request.
Request lifecycle order:
- Route → Auth middleware →
SectionController::modules() - Section ownership check via
SectionRepository - Module structure fetched (or served from Cache)
- Completion map pre-fetched (1 query)
- Availability loop evaluates each module against the map
- Hierarchy built + resource serialized with
name
Three concrete problems drove this work:
- Missing name — The previous response had no
namefield, making it impossible for the frontend to render meaningful module titles. - N+1 queries — For a section with 29 modules each carrying a completion condition, the endpoint fired 29–58 individual DB queries (one per
CompletionCondition::evaluate()call). - Repeat cost — Course structure is teacher-driven and changes infrequently, yet every student request re-executed the same expensive multi-relation Eloquent query.
- Course module —
app/Modules/Course/(Controller, Repository, Resource) - Availability module —
app/Modules/Availability/(DTO, Condition) - Shared models —
app/Shared/Models/(MoodleCourseModule + 2 new plugin models) - Feature tests —
tests/Feature/Course/SectionTest.php - Unit tests —
tests/Unit/Shared/Models/+tests/Unit/Availability/Conditions/ - Docs —
docs/openapi.yaml,docs/postman_collection.json
Phase 1 — Eager loading names: Two new read-only Eloquent models (MoodleAssign, MoodleQuiz) extend MoodleModel. Three new HasOne relations (assign(), quiz(), lesson()) were added to MoodleCourseModule. A getModuleName(): ?string method resolves the name using a match expression on the eager-loaded moduleType. ModuleRepository::listForSection() now includes all six plugin relations in its with() call. HierarchyModuleResource::toArray() exposes the name field.
Phase 2 — N+1 elimination: AvailabilityContext (a final readonly DTO) gains an optional completionMap: array<int, int> property (default []). SectionController::applyAvailability() issues one whereIn query across all module IDs in the section and converts the result to a [moduleId => state] map via mapWithKeys(). CompletionCondition::evaluate() checks array_key_exists($cmId, $context->completionMap) first; only falls back to a DB query when the key is absent (single-module show path).
Phase 3 — Structure cache: The Eloquent query in ModuleRepository::listForSection() is wrapped in Cache::remember("course.{courseId}.section.{sectionId}.modules", now()->addHour(), fn() => ...). The cache is student-agnostic — only static structure (module IDs, types, names, visibility) is stored. Availability evaluation happens after cache retrieval and is never persisted.
Sequence Diagram
WITH assign, quiz, lesson, page, label, url, moduleType DB-->>ModuleRepo: Collection
FROM course_modules_completion
WHERE userid=? AND coursemoduleid IN (?) DB-->>Controller: completionMap [moduleId => state] loop Each module in collection Controller->>AvailSvc: evaluate(module.availability, AvailabilityContext{completionMap}) AvailSvc->>Condition: evaluate(condition, not, context) alt Module ID in completionMap Condition->>Condition: state = completionMap[cmId] else Not in map (single-module fallback) Condition->>DB: SELECT completionstate WHERE coursemoduleid=? AND userid=? DB-->>Condition: row | null end Condition-->>AvailSvc: AvailabilityResult AvailSvc-->>Controller: AvailabilityResult {available, show, reason} end Controller->>Resource: HierarchyModuleResource::collection(hierarchy) Resource->>Resource: toArray() — id, module_type, name (getModuleName()),
instance, indent, visible, completion, available, available_reason, children Resource-->>Controller: JSON array Controller-->>Browser: 200 {"success":true,"data":{"section":{...},"modules":[...]}}
Flowchart
Files Changed
Performance Impact
| Scenario | Before | After | Saving |
|---|---|---|---|
| Section list, 29 modules, first load (cache cold) | ~50 queries | ~9 queries | ~82% fewer |
| Section list, 29 modules, second load (cache warm) | ~50 queries | 1 cache read + ~4 queries (completion only) | ~92% fewer |
| Second student, same section (cross-student cache) | ~50 queries | 0 structure queries (from cache) | Full structure savings |
| Module show (single — ModuleService::show()) | unchanged | unchanged | No regression |
Cache TTL: 1 hour · Key: course.{courseId}.section.{sectionId}.modules · Invalidation: TTL-based only (teacher changes are infrequent; webhook bust available as future option).
Rules Applied
Repository layer owns all DB queries
All cache logic and Eloquent eager-loading lives in ModuleRepository. The controller calls the repository and never issues raw DB queries directly — except for the completion pre-fetch which is intentionally in the controller because it requires the student ID and the module collection produced by the repository in the same request scope.
Shared models are read-only
MoodleAssign and MoodleQuiz extend MoodleModel which throws a \LogicException on any save() or delete() call. $table is set to the bare Moodle table name — the DB_TABLE_PREFIX env applies automatically. No timestamps. Tests enforce these constraints with test_it_is_read_only().
DTOs are final readonly value objects
AvailabilityContext is declared final readonly. The new completionMap property uses constructor promotion with a typed default. No business logic in the DTO. Follows the architecture rule that DTOs carry data between layers, not behaviour.
Bind interface, not concrete
ModuleRepositoryInterface::listForSection() was updated in lockstep with ModuleRepository to keep the contract accurate. The controller depends on the interface. Adding the int $courseId parameter is a breaking interface change that was coordinated across both files.
match over switch
MoodleCourseModule::getModuleName() uses a match ($type) expression with all six supported plugin types and a default => null arm. No switch, no nested if/else chain.
Laravel Collection over raw PHP arrays
The completion pre-fetch uses ->mapWithKeys(fn (...) => [...]) instead of array_column or manual loops. Sequence ordering uses Collection::sortBy() with array_search — necessary because MySQL's FIELD() function is unavailable in SQLite (used in tests).
Named arguments for clarity
AvailabilityContext construction in the controller uses named arguments (studentId:, courseId:, completionMap:, etc.), improving readability for a constructor with 5 parameters and making the optional completionMap unambiguous.
PHPDoc on every class and public method
All new and modified public methods carry a brief PHPDoc with @param annotations where the type alone is insufficient (e.g. array<int, int> $completionMap). The cache behaviour and PHP-side sort rationale are documented inline with // comments explaining WHY, not WHAT.
TDD workflow — Red → Green → Refactor
Tests were written before implementation in all three phases. Phase 2's test_section_modules_fires_single_completion_query() uses DB::enableQueryLog() to assert ≤1 completion query, which would fail before the pre-fetch fix. Phase 3's cache tests assert 0 course_modules queries on the second call.
Feature tests hit the real DB (RefreshDatabase)
All feature tests in SectionTest.php use the RefreshDatabase trait and Schema::create() in setUp() to build the exact Moodle table structure needed in SQLite. No mocking of Eloquent or the DB driver.
Unit tests verify model contracts, not behaviour
MoodleCourseModuleTest unit tests call setRelation() directly to inject eager-loaded models without a DB, validating getModuleName()'s match logic in isolation. No RefreshDatabase needed — pure in-memory object assertions.
No student-specific data in shared cache
The cache key is course+section only, never includes student ID. Availability evaluation (which is student-specific) always runs after cache retrieval and is never persisted. This prevents data leakage between students while still delivering the structural caching benefit.