🗂 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 own Cache::remember
  • When CourseService builds 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 ModuleRepository cached per-section (8 entries for 8 sections, no shared invalidation)
  • No invalidation: fixed 1-hour TTL, unaware of Moodle's cacherev mechanism — 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 + contract
  • app/Shared/CourseModinfo/Contracts/CourseModinfoServiceInterface.php
  • app/Shared/CourseModinfo/DTOs/CourseModinfoDTO, SectionModinfoDTO, ModuleModinfoDTO
  • app/Modules/Course/Repositories/ModuleRepository.php — updated caller
  • app/Modules/Course/Services/CourseService.php — updated caller (2 methods)
  • app/Providers/AppServiceProvider.php — singleton binding
  • tests/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 CourseModinfoDTO for this course with a matching cacherev, return it immediately — zero additional DB work.
  • L2 hit: if Laravel Cache has a stored raw-array payload whose cacherev matches 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_sections then course_modules with eager relations), build typed DTOs, dehydrate to plain arrays (Redis-safe), store in L2 (24h) and L1.
  • ModuleModinfoDTO::toEloquentModel() reconstructs a MoodleCourseModule instance from cached raw attributes and relations — existing callers receive the same type they always expected, requiring zero changes downstream.
  • The singleton is bound in AppServiceProvider so 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 in AppServiceProvider — 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-only MoodleCourseModule model
  • 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\Cache and DB used 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/@return omitted when type signature is self-explanatory
  • Single quotes for all plain strings; double quotes only in interpolation
  • Explicit use imports, 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 MoodleCourseModule model's write guard remains in place
  • cacherev validation 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
  • RefreshDatabase trait used; test data seeded via DB::table()->insertGetId() in setUp() (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, existing CourseModinfoTest, CompletionConditionTest) remain green after the refactor