CourseModinfo Service
Overview — The 5 Ws
What
A shared CourseModinfoService that mirrors Moodle's get_fast_modinfo(): a two-level, cacherev-validated course structure cache. It owns L1 (in-memory, 10-entry LRU on the singleton instance) and L2 (Laravel Cache, 24-hour TTL) caching for the full structural snapshot of every course — all sections and their ordered module lists, including display names from plugin tables. Returns clean DTOs; never exposes per-student data.
When
- Every HTTP request that loads a course's section list (
SectionController) or course detail (CourseController::show()) - When
ModuleRepository::listForSection()is called — delegates entirely to this service instead of its ownCache::remember - When
CourseServicebuilds availability/completion maps or module context maps for a course - Future study-plan requests that need the full course structure in a single call
Why
- Fragmented cache: the old
ModuleRepositorycached per-section (8 entries for 8 sections, no shared invalidation) - No invalidation: fixed 1-hour TTL, unaware of Moodle's
cacherevmechanism — stale data after teacher edits - Study-plan support: future feature needs the entire course structure in one query, not N section round-trips
- N+1 elimination: all modules across all sections loaded in a single query; availability/completion maps built in one pass
Where
app/Shared/CourseModinfo/— service + DTOs + contractapp/Shared/CourseModinfo/Contracts/CourseModinfoServiceInterface.phpapp/Shared/CourseModinfo/DTOs/—CourseModinfoDTO,SectionModinfoDTO,ModuleModinfoDTOapp/Modules/Course/Repositories/ModuleRepository.php— updated callerapp/Modules/Course/Services/CourseService.php— updated caller (2 methods)app/Providers/AppServiceProvider.php— singleton bindingtests/Unit/Shared/CourseModinfo/CourseModinfoServiceTest.php
How
On every get($courseId) call the service performs a single lightweight DB read (SELECT cacherev FROM course WHERE id = ?), then walks the two-level cache:
- L1 hit: if the singleton's in-memory array has a
CourseModinfoDTOfor this course with a matchingcacherev, return it immediately — zero additional DB work. - L2 hit: if Laravel Cache has a stored raw-array payload whose
cacherevmatches the DB value, deserialize it (hydrate()), promote to L1, and return. - Miss or stale: load all sections + modules + plugin relations in a two-query DB round-trip (
course_sectionsthencourse_moduleswith eager relations), build typed DTOs, dehydrate to plain arrays (Redis-safe), store in L2 (24h) and L1. ModuleModinfoDTO::toEloquentModel()reconstructs aMoodleCourseModuleinstance from cached raw attributes and relations — existing callers receive the same type they always expected, requiring zero changes downstream.- The singleton is bound in
AppServiceProviderso the L1 array persists across multiple service/repository calls within a single PHP request lifecycle.
Sequence Diagram
sequenceDiagram
autonumber
participant Browser
participant Controller as SectionController / CourseController
participant CourseService
participant ModuleRepo as ModuleRepository
participant CMService as CourseModinfoService
participant L1 as L1 Cache (PHP array)
participant L2 as L2 Cache (Laravel Cache)
participant DB as MySQL (Moodle DB)
Browser->>Controller: GET /api/v1/courses/{id}/sections
Controller->>CourseService: getModuleAvailabilityAndCompletionMaps(course, studentId)
CourseService->>CMService: get(courseId)
CMService->>DB: SELECT cacherev FROM course WHERE id = ?
DB-->>CMService: cacherev = 42
alt L1 hit (cacherev matches)
CMService->>L1: lookup courseId → CourseModinfoDTO
L1-->>CMService: dto (cacherev=42) ✓
CMService-->>CourseService: CourseModinfoDTO
else L2 hit (cacherev matches)
CMService->>L2: Cache::get("course_modinfo.{id}")
L2-->>CMService: raw array (cacherev=42)
CMService->>CMService: hydrate(raw) → CourseModinfoDTO
CMService->>L1: storeL1(courseId, dto)
CMService-->>CourseService: CourseModinfoDTO
else Cache miss or stale cacherev
CMService->>DB: SELECT * FROM course_sections WHERE course = ?
DB-->>CMService: section rows
CMService->>DB: SELECT cm.* + eager load moduleType, assign, quiz, lesson…
DB-->>CMService: module rows with plugin relations
CMService->>CMService: build() → CourseModinfoDTO
CMService->>L2: Cache::put("course_modinfo.{id}", dehydrate(dto), 86400)
CMService->>L1: storeL1(courseId, dto)
CMService-->>CourseService: CourseModinfoDTO
end
CourseService-->>Controller: [availabilityMap, completionMap]
Controller->>ModuleRepo: listForSection(courseId, sectionId, sequence)
ModuleRepo->>CMService: get(courseId)
CMService->>L1: lookup (L1 warm from above call)
L1-->>CMService: CourseModinfoDTO ✓
CMService-->>ModuleRepo: CourseModinfoDTO
ModuleRepo->>ModuleRepo: filter section modules → toEloquentModel()
ModuleRepo-->>Controller: Collection of MoodleCourseModule
Controller-->>Browser: 200 JSON { data: [...sections with modules] }
Flowchart — Cache Resolution Logic
flowchart TD
A(["get(courseId) called"]) --> B["SELECT cacherev FROM course WHERE id=?"]
B --> C{"L1 entry exists?"}
C -- Yes --> D{"L1 cacherev == DB cacherev?"}
D -- Yes --> RETURN_L1(["✓ return L1 DTO — zero extra work"])
D -- No --> E["L1 miss / stale → try L2"]
C -- No --> E
E --> F["Cache::get 'course_modinfo.id'"]
F --> G{"L2 entry exists?"}
G -- No --> BUILD
G -- Yes --> H{"L2 cacherev == DB cacherev?"}
H -- Yes --> I["hydrate raw array → CourseModinfoDTO"]
I --> J["storeL1()"]
J --> RETURN_L2(["✓ return hydrated DTO from L2"])
H -- No --> BUILD
BUILD["Build from DB"]
BUILD --> K["SELECT course_sections ORDER BY section"]
K --> L{"Sections exist?"}
L -- No --> EMPTY(["return empty CourseModinfoDTO"])
L -- Yes --> M["SELECT course_modules WHERE section IN ... WITH eager relations"]
M --> N["Sort modules by section.sequence field"]
N --> O["Filter deletioninprogress = 0"]
O --> P["buildModuleDTO() for each module"]
P --> Q["Assemble SectionModinfoDTO"]
Q --> R["Assemble CourseModinfoDTO"]
R --> S["Cache::put L2 dehydrated array TTL 86400s"]
S --> T["storeL1() — evict oldest if 10 full"]
T --> RETURN_DB(["✓ return freshly built DTO"])
style RETURN_L1 fill:#10b981,color:#fff,stroke:#10b981
style RETURN_L2 fill:#10b981,color:#fff,stroke:#10b981
style RETURN_DB fill:#818cf8,color:#fff,stroke:#818cf8
style EMPTY fill:#64748b,color:#fff,stroke:#64748b
style BUILD fill:#1a1d27,color:#e2e8f0,stroke:#2a2d3e
Files Changed
| File Path | Layer | Description |
|---|---|---|
| app/Shared/CourseModinfo/Contracts/CourseModinfoServiceInterface.php | Contract | Defines the two public methods (get, invalidate) that callers depend on. Ensures the singleton is bound to a type the DI container resolves. |
| app/Shared/CourseModinfo/CourseModinfoService.php | Service | Core implementation: L1 array with LRU eviction, L2 Laravel Cache with 24h TTL, cacherev validation, DB build (build()), serialization (dehydrate/hydrate), and invalidate(). |
| app/Shared/CourseModinfo/DTOs/CourseModinfoDTO.php | DTO | Top-level immutable snapshot: courseId, cacherev, ordered SectionModinfoDTO[]. Provides allModuleIds() and allModules() helpers for bulk operations. |
| app/Shared/CourseModinfo/DTOs/SectionModinfoDTO.php | DTO | Immutable structural snapshot of a course section: id, number, name, summary, sequence, visibility, availability JSON, and ordered ModuleModinfoDTO[]. |
| app/Shared/CourseModinfo/DTOs/ModuleModinfoDTO.php | DTO | Immutable snapshot of a single course module. Stores rawAttributes and rawRelations arrays, and provides toEloquentModel() to reconstruct a MoodleCourseModule instance for downstream callers. |
| app/Modules/Course/Repositories/ModuleRepository.php | Repository | Updated: listForSection() now delegates to CourseModinfoServiceInterface instead of owning a Cache::remember. Reconstructs MoodleCourseModule instances via ModuleModinfoDTO::toEloquentModel(). |
| app/Modules/Course/Services/CourseService.php | Service | Updated: getModuleAvailabilityAndCompletionMaps() and getModuleContextMap() now use CourseModinfoService::get() for module IDs instead of iterating eager-loaded Eloquent relations. |
| app/Providers/AppServiceProvider.php | Provider | Registers CourseModinfoServiceInterface → CourseModinfoService as a singleton so the L1 in-memory array persists across all service/repository calls within a single request. Injects it into CourseService and ModuleRepository. |
| tests/Unit/Shared/CourseModinfo/CourseModinfoServiceTest.php | Test | 9 unit tests covering: empty course, DB build with sections + modules, L2 cache hit (valid cacherev), stale cacherev triggers rebuild, L1 hit (same instance), invalidate(), L1 LRU eviction at 11 entries, allModuleIds() helper, and toEloquentModel() reconstruction. |
Rules Applied
Architecture
- Shared infrastructure placed in
app/Shared/CourseModinfo/— cross-cutting concern outside any single module - Service layer owns all business logic (cache strategy, cacherev validation, DB build); repositories and controllers delegate to it without re-implementing caching
- Interface (
CourseModinfoServiceInterface) bound inAppServiceProvider— callers depend on the contract, not the concrete class - Singleton binding ensures L1 lifetime matches the PHP request lifecycle (service provider comment documents why)
- No write access to Moodle tables — only reads via
DB::table()and read-onlyMoodleCourseModulemodel - DTOs (
final readonly) used to pass data between layers; no Eloquent models cross the service boundary as structural data
Coding Style
- All classes declared
final(no inheritance needed or designed) - All properties and parameters fully typed; no
mixed - Constructor promotion on all three DTOs (
final readonly) - Named arguments used throughout DTO construction for clarity at call sites
Illuminate\Support\Facades\CacheandDBused instead of raw PHP caching or PDO- First-class callable syntax
fn (MoodleCourseModule $m) => ...for collection transforms - PHPDoc on every class and every public method;
@param/@returnomitted when type signature is self-explanatory - Single quotes for all plain strings; double quotes only in interpolation
- Explicit
useimports, no inline fully-qualified class names
Security
- All DB queries use Eloquent / Query Builder with parameter binding — no raw SQL string interpolation (
DB::table('course')->where('id', $courseId)) - No per-student data (completion states, availability results) stored in the cache — structural data only, preventing cross-student data leakage via cache poisoning
- Moodle tables accessed read-only; the
MoodleCourseModulemodel's write guard remains in place cacherevvalidation prevents stale data from reaching students after teacher edits — a correctness-and-security concern for availability conditions
Testing
- TDD workflow followed: tests written to cover every specified behaviour before finalising the implementation
RefreshDatabasetrait used; test data seeded viaDB::table()->insertGetId()insetUp()(no Moodle model factories per project rule)- Test names follow
test_it_*snake_case convention and describe behaviour, not implementation - AAA (Arrange/Act/Assert) pattern in every test method
- Both L1 and L2 cache paths have dedicated tests; stale-cacherev rebuild has its own test case
- LRU eviction at exactly 10 entries verified with an 11-course loop
- Event dispatching not applicable here (no domain events for a cache service)
- All existing tests (
SectionTest, existingCourseModinfoTest,CompletionConditionTest) remain green after the refactor