Overview (5W)

🧩
What

Three progressive enhancements to the section modules endpoint:

  • Phase 1 — Adds a name field (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.
When

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
💡
Why

Three concrete problems drove this work:

  • Missing name — The previous response had no name field, 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.
📁
Where
  • Course moduleapp/Modules/Course/ (Controller, Repository, Resource)
  • Availability moduleapp/Modules/Availability/ (DTO, Condition)
  • Shared modelsapp/Shared/Models/ (MoodleCourseModule + 2 new plugin models)
  • Feature teststests/Feature/Course/SectionTest.php
  • Unit teststests/Unit/Shared/Models/ + tests/Unit/Availability/Conditions/
  • Docsdocs/openapi.yaml, docs/postman_collection.json
⚙️
How

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

sequenceDiagram autonumber participant Browser participant Middleware as Auth Middleware participant Controller as SectionController participant SectionRepo as SectionRepository participant ModuleRepo as ModuleRepository participant Cache as Laravel Cache participant DB as MySQL (Moodle DB) participant AvailSvc as AvailabilityService participant Condition as CompletionCondition participant Resource as HierarchyModuleResource Browser->>Middleware: GET /api/v1/courses/{courseId}/sections/{sectionId}/modules Middleware->>Middleware: Verify Sanctum token alt Unauthenticated Middleware-->>Browser: 401 Unauthorized end Middleware->>Controller: modules(ListModulesRequest) Controller->>SectionRepo: findForStudent(ShowSectionDTO) SectionRepo->>DB: SELECT course_sections WHERE id=? + enrolment JOIN alt Section not found / not enrolled SectionRepo-->>Controller: SectionNotFoundException Controller-->>Browser: 404 {"error": {"code": "SECTION_NOT_FOUND"}} end SectionRepo-->>Controller: MoodleCourseSection Controller->>ModuleRepo: listForSection(courseId, sectionId, sequence) ModuleRepo->>Cache: get("course.{courseId}.section.{sectionId}.modules") alt Cache HIT Cache-->>ModuleRepo: Collection (with eager relations) else Cache MISS ModuleRepo->>DB: SELECT course_modules WHERE id IN (?) AND visible=1
WITH assign, quiz, lesson, page, label, url, moduleType DB-->>ModuleRepo: Collection ModuleRepo->>Cache: put(key, collection, +1 hour) Cache-->>ModuleRepo: ok end ModuleRepo-->>Controller: Collection Note over Controller,DB: Phase 2 — Pre-fetch completion map (1 query) Controller->>DB: SELECT coursemoduleid, completionstate
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

flowchart TD A([GET sections/sectionId/modules]) --> B{Authenticated?} B -- No --> B1([401 Unauthorized]) B -- Yes --> C[SectionRepository::findForStudent] C --> D{Section exists\n& enrolled?} D -- No --> D1([404 Section Not Found]) D -- Yes --> E[ModuleRepository::listForSection\ncourseId + sectionId + sequence] E --> F{Cache hit?\ncourse.courseId.section.sectionId.modules} F -- HIT --> G[Return cached Collection] F -- MISS --> H[Eloquent query:\ncourse_modules WHERE id IN seq\nWITH assign,quiz,lesson,page,label,url] H --> I[Sort by sequence order in PHP] I --> J[Cache::remember +1 hour] J --> G G --> K[Pre-fetch completion map\n1x WHERE userid=? AND coursemoduleid IN ?] K --> L[Loop each module] L --> M{Has availability JSON?} M -- No --> N[AvailabilityResult::available] M -- Yes --> O[AvailabilityService::evaluate] O --> P{Condition type} P -- date --> Q[DateCondition: compare Carbon now vs threshold] P -- completion --> R{cmId in completionMap?} R -- Yes --> S[state = completionMap cmId] R -- No --> T[Fallback DB query] T --> S S --> U{state matches\nexpectation?} U -- Yes --> N U -- No --> V{show = true?} V -- Yes --> W[AvailabilityResult::locked\navailable_reason message] V -- No --> X[Exclude module from output] N --> Y W --> Y X --> L Y{More\nmodules?} Y -- Yes --> L Y -- No --> Z[ModuleHierarchyBuilder::buildHierarchy\ngroup by indent level] Z --> AA[HierarchyModuleResource::collection\nid, module_type, name, instance, indent,\nvisible, completion, available, available_reason, children] AA --> AB([200 OK\ndata.section + data.modules]) style B1 fill:#ef4444,color:#fff,stroke:#ef4444 style D1 fill:#ef4444,color:#fff,stroke:#ef4444 style AB fill:#10b981,color:#fff,stroke:#10b981 style F fill:#1a1d27,stroke:#818cf8,color:#a5b4fc style J fill:#1a1d27,stroke:#818cf8,color:#a5b4fc

Files Changed

File Path Layer Description
✦ New Files
app/Shared/Models/MoodleAssign.php Model Read-only Eloquent model for Moodle's assign table. Extends MoodleModel (read-only guard). Used to resolve the display name for assignment-type course modules via the assign() relation on MoodleCourseModule.
app/Shared/Models/MoodleQuiz.php Model Read-only Eloquent model for Moodle's quiz table. Extends MoodleModel. Used to resolve the display name for quiz-type course modules via the quiz() relation.
tests/Unit/Shared/Models/MoodleAssignTest.php Test Unit tests verifying MoodleAssign table name, read-only guard, and timestamp disablement.
tests/Unit/Shared/Models/MoodleQuizTest.php Test Unit tests verifying MoodleQuiz table name, read-only guard, and timestamp disablement.
✦ Modified Files — Phase 1 (Eager Loading Names)
app/Shared/Models/MoodleCourseModule.php Model Added three new HasOne relations: assign(), quiz(), lesson() (all point into plugin tables via instance local key). Added getModuleName(): ?string which uses a match on moduleType->name to read the correct eager-loaded relation's name column. Returns null for unsupported types or unloaded relations.
app/Modules/Course/Repositories/ModuleRepository.php Repository Phase 1: listForSection() eager-loads all six plugin name relations (moduleType, assign, quiz, lesson, page, label, url). Phase 3: wraps the query in Cache::remember("course.{courseId}.section.{sectionId}.modules", now()->addHour(), ...). Added int $courseId parameter.
app/Modules/Course/Repositories/ModuleRepositoryInterface.php Repository Updated listForSection() signature to include the new int $courseId parameter required by the cache key construction in Phase 3.
app/Modules/Course/Resources/HierarchyModuleResource.php Resource Added 'name' => $this->resource['module']->getModuleName() field between module_type and instance. Exposes the resolved plugin name in the JSON response as string|null.
✦ Modified Files — Phase 2 (N+1 Fix)
app/Modules/Availability/DTOs/AvailabilityContext.php DTO Added optional constructor-promoted property public array $completionMap = [] (typed array<int, int>). Carries the pre-fetched [moduleId => completionState] map from the controller to every condition evaluator, avoiding per-module DB queries in list endpoints.
app/Modules/Availability/Conditions/CompletionCondition.php Service Updated evaluate() to check array_key_exists($cmId, $context->completionMap) before issuing a DB query. Map hit path: reads state directly from the array. Miss path: falls back to existing MoodleCourseModuleCompletion::query() — preserves backward-compatibility for single-module show requests.
app/Modules/Course/Controllers/SectionController.php Controller Refactored applyAvailability() to pre-fetch all completion records for the section in a single whereIn query. Builds $completionMap via mapWithKeys() and injects it into each AvailabilityContext. Also passes $dto->courseId to the updated listForSection() signature.
✦ Modified Files — Tests
tests/Unit/Shared/Models/MoodleCourseModuleTest.php Test Added 8 new unit tests covering getModuleName(): returns correct name for assign, quiz, lesson, page, label, and url; returns null for unknown types; returns null when relations or moduleType are not loaded.
tests/Unit/Availability/Conditions/CompletionConditionTest.php Test Added 4 new tests: test_it_uses_completion_map_when_provided(), test_it_falls_back_to_db_when_module_not_in_map(), test_it_handles_empty_completion_map(), test_it_treats_map_state_zero_as_not_completed().
tests/Feature/Course/SectionTest.php Test Added 6 new feature tests covering all three phases: module name in response, null name for unsupported types, single completion query assertion (query log), cache structure test (second call fires 0 structure queries), and cross-student cache sharing.
✦ Modified Files — Documentation
docs/openapi.yaml Docs Added HierarchyModule and CourseSection schemas. Added name: string|null field to HierarchyModule. Added /v1/courses/{courseId}/sections and /v1/courses/{courseId}/sections/{sectionId}/modules endpoint definitions.
docs/postman_collection.json Docs Updated section modules 200 example responses to include the name field in all hierarchy module objects.

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

Architecture

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.

Architecture

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().

Architecture

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.

Architecture

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.

Coding Style

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.

Coding Style

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).

Coding Style

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.

Coding Style

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.

Testing

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.

Testing

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.

Testing

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.

Security

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.