5W Overview

📦 What
Fetches mdl_label content for label-type course modules and surfaces it in the course modinfo response. Adds a name field (from mdl_label.name) and a content block containing intro, introformat, and timemodified. Resolves @@PLUGINFILE@@ tokens in intro to portal-local URLs. The module url is always null (Moodle's FEATURE_NO_VIEW_LINK).
⏱️ When
Triggered on every GET /api/v1/courses/{id} request when the course contains label-type modules. Executes inside the resource serialization pipeline: CourseResource → SectionResource → ModuleResource. The resolver threading chain was already established by the page-module-content feature — no new middleware or routing is involved.
💡 Why
PM requirement: the portal must render Label activity content inline on the course page. Prior to this feature, label modules returned name: null and had no content block, making them invisible to the frontend. Labels are visually prominent — they are the primary mechanism for displaying rich HTML content blocks between activities in Moodle.
📍 Where
New file: app/Shared/Models/MoodleLabel.php
Modified: app/Shared/Models/MoodleCourseModule.php (new label() relation), app/Modules/Course/Repositories/CourseRepository.php (eager load), app/Modules/Course/Resources/ModuleResource.php (label branch + resolver).
Tests: tests/Feature/Course/LabelModuleContentTest.php (new), plus setUp updates in CourseModinfoTest and PageModuleContentTest.
⚙️ How
CourseRepository::findForStudent() eager-loads ['moduleType', 'page', 'label'] on each module. The label relation on MoodleCourseModule is a HasOne into mdl_label via instance → id. In ModuleResource::toArray(), an elseif ($modname === 'label') branch calls resolveLabelContent(), which resolves @@PLUGINFILE@@ tokens using the injected PluginfileUrlResolverInterface and ContextResolverInterface. If context resolution fails, the raw intro is returned gracefully (no crash). Non-label modules are completely unaffected.

Sequence Diagram

sequenceDiagram autonumber participant B as Browser participant MW as Auth Middleware participant C as CourseController participant S as CourseService participant R as CourseRepository participant DB as MySQL (Moodle DB) participant CR as CourseResource participant SR as SectionResource participant MR as ModuleResource participant CTX as ContextResolver participant PF as PluginfileResolver B->>MW: GET /api/v1/courses/{id} MW->>MW: Validate Sanctum token MW->>C: show(CourseShowRequest, id) C->>S: show(courseId, studentId) S->>R: findForStudent(courseId, studentId) R->>DB: SELECT course WHERE id=? + enrollment check DB-->>R: MoodleCourse row R->>DB: eager load sections → modules → [moduleType, page, label] DB-->>R: sections + modules + label rows R-->>S: MoodleCourse (hydrated) S-->>C: MoodleCourse C->>CR: new CourseResource(course) CR->>CR: withModuleContentResolvers(contextResolver, pluginfileResolver) CR->>SR: SectionResource per section SR->>SR: withModuleContentResolvers(pluginfileResolver, contextResolver) SR->>MR: ModuleResource per module MR->>MR: toArray() — detect modname='label' MR->>CTX: resolve(ContextLevel::Module, cmId) CTX->>DB: SELECT id FROM context WHERE contextlevel=70, instanceid=cmId alt context found DB-->>CTX: contextId CTX-->>MR: contextId MR->>PF: resolve(intro, contextId, mod_label, intro, 0) PF-->>MR: resolved intro HTML else context missing DB-->>CTX: (empty) CTX-->>MR: throws RuntimeException MR->>MR: catch Throwable — use raw intro end MR-->>SR: {name, url:null, content:{intro, introformat, timemodified}, ...} SR-->>CR: section array with modules CR-->>C: course JSON C-->>B: 200 {data: {sections: [{modules: [{modname:'label', name, content:{intro,...}}]}]}}

Flowchart

flowchart TD A([GET /api/v1/courses/id]) --> B{Token valid?} B -- No --> B1[401 Unauthenticated] B -- Yes --> C{Student enrolled\nin course?} C -- No --> C1[404 CourseNotFoundException] C -- Yes --> D[CourseRepository::findForStudent\neager load: sections.modules.\nmoduleType + page + label] D --> E[CourseResource serializes course] E --> F[SectionResource per section\nwithModuleContentResolvers] F --> G[ModuleResource per module] G --> H{modname?} H -- page --> H1[resolvePageContent\nmod_page / content / intro] H -- label --> I{module.label != null?} H -- other --> J[name=null, no content key] I -- No --> J I -- Yes --> K[set name = label.name\ncall resolveLabelContent] K --> L{pluginfileResolver\n& contextResolver\navailable?} L -- No --> M[return raw intro as-is] L -- Yes --> N[contextResolver.resolve\nContextLevel::Module, cmId] N --> O{Context row\nexists in DB?} O -- No / throws --> P[catch Throwable\nreturn raw intro] O -- Yes --> Q[pluginfileResolver.resolve\nintro, contextId, mod_label, intro, 0] Q --> R[@@PLUGINFILE@@ → portal URL] R --> S[return content block\nintro + introformat + timemodified] P --> S M --> S S --> T[url always null\nFEATURE_NO_VIEW_LINK] H1 --> U[200 response assembled] T --> U J --> U U --> V([200 OK — course modinfo JSON]) style B1 fill:#ef4444,color:#fff,stroke:none style C1 fill:#ef4444,color:#fff,stroke:none style V fill:#10b981,color:#fff,stroke:none

Files Changed

File Path Layer Change Type Description
app/Shared/Models/MoodleLabel.php Model New Read-only Eloquent model for Moodle's mdl_label table. Extends MoodleModel (write-protected). Casts introformat and timemodified to integer. No content, display, displayoptions, or revision casts — those columns do not exist in mdl_label.
app/Shared/Models/MoodleCourseModule.php Model Modified Added label(): HasOne relation pointing to MoodleLabel via hasOne(MoodleLabel::class, 'id', 'instance'). Returns null for non-label modules — no matching rows exist in mdl_label for other module types.
app/Modules/Course/Repositories/CourseRepository.php Repository Modified Updated findForStudent() eager load from ['moduleType', 'page'] to ['moduleType', 'page', 'label']. Ensures the label relation is pre-loaded in a single query, avoiding N+1 queries per module.
app/Modules/Course/Resources/ModuleResource.php Resource Modified Added use App\Shared\Models\MoodleLabel. Added elseif ($modname === 'label' && $module->label !== null) branch in toArray() to set name and content. Added private resolveLabelContent(MoodleLabel $label, int $cmId): array — resolves @@PLUGINFILE@@ in intro using mod_label / intro / 0; catches \Throwable for graceful degradation. Updated resolveUrl() to return null for modname === 'label'.
tests/Feature/Course/LabelModuleContentTest.php Test New 6 feature tests covering: label name & content block presence; url always null; @@PLUGINFILE@@ token resolution; graceful degradation when context resolver throws; correct metadata fields (introformat, timemodified); page and label modules coexisting in the same section. Full in-memory schema setup via RefreshDatabase.
tests/Feature/Course/CourseModinfoTest.php Test Modified Added label table creation to setUp(). Required because CourseRepository now eager-loads the label relation — without the table in the test DB the eager load throws a missing-table error.
tests/Feature/Course/PageModuleContentTest.php Test Modified Added label table creation to setUp(). Same reason as CourseModinfoTest — the shared eager load now always includes label, so the table must exist in all tests that exercise the course show endpoint.

Rules Applied

Architecture
  • Read-only Moodle modelMoodleLabel extends MoodleModel, which overrides save() and delete() to throw LogicException. No write access to Moodle tables.
  • Repository eager-loading — The label relation is loaded in CourseRepository, not in the resource, keeping DB concerns in the repository layer.
  • Thin controller — Zero controller changes. All logic flows through the existing service → repository → resource pipeline.
  • No layer skipping — ModuleResource receives resolvers via dependency injection from SectionResource, which receives them from CourseResource, which receives them from the controller. No resolver is resolved ad-hoc.
  • Module self-containment — The shared model lives in app/Shared/Models/ (cross-cutting Moodle read access); course-specific logic stays in app/Modules/Course/.
Coding Style
  • final classesMoodleLabel and LabelModuleContentTest are both final (default for all classes).
  • declare(strict_types=1) — Present in every new and modified file.
  • Explicit return typesresolveLabelContent(): array, label(): HasOne, all methods fully typed.
  • No magic strings'label' appears only as a modname comparator, mirroring Moodle's own string. ContextLevel::Module enum used for context level.
  • PHPDoc on every class and public method — Class-level docblock on MoodleLabel; @throws not applicable (no exceptions thrown publicly); method docblock on label() and resolveLabelContent().
  • Laravel Pint — Run to zero violations before completion (Phase 5 of tasks).
Security
  • No write access to Moodle tablesMoodleLabel is read-only; the model-level guard prevents any accidental write via generic Eloquent.
  • No new endpoints — The existing auth guard (Sanctum) on GET /api/v1/courses/{id} applies automatically; no authorization gap introduced.
  • Error details never leak — Context resolution failures are caught with catch (\Throwable) and silently degraded; no stack trace or internal error message reaches the API response.
  • Token replacement produces portal URLs@@PLUGINFILE@@ tokens are replaced with portal-proxied URLs via PluginfileUrlResolver, not direct Moodle storage URLs.
Testing
  • TDD workflow — Tests written before implementation (Red → Green → Refactor).
  • RefreshDatabase trait — Feature tests use in-memory SQLite with the full schema recreated in setUp(), ensuring isolation.
  • AAA pattern — Every test method follows Arrange / Act / Assert with clear section comments.
  • No mocking what we don't own — Eloquent models and the database are real; only the external ContextResolverInterface and PluginfileUrlResolverInterface are mocked where behaviour needs to be controlled.
  • Naming convention — All test methods follow test_it_* snake_case pattern describing the expected behaviour.
  • Edge cases covered — Missing context (graceful degradation), url null enforcement, metadata field correctness, and mixed module types in the same section.
  • Pre-existing tests remain greenCourseModinfoTest and PageModuleContentTest updated to include the label table so the now-wider eager load does not break them.