Label Module Content
Generated: 2026-04-09 · Module: Course · Endpoint:
GET /api/v1/courses/{id} (enhanced)
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:
Modified:
Tests:
app/Shared/Models/MoodleLabel.phpModified:
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 model —
MoodleLabelextendsMoodleModel, which overridessave()anddelete()to throwLogicException. No write access to Moodle tables. - Repository eager-loading — The
labelrelation is loaded inCourseRepository, 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 fromCourseResource, 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 inapp/Modules/Course/.
Coding Style
- final classes —
MoodleLabelandLabelModuleContentTestare bothfinal(default for all classes). - declare(strict_types=1) — Present in every new and modified file.
- Explicit return types —
resolveLabelContent(): array,label(): HasOne, all methods fully typed. - No magic strings —
'label'appears only as amodnamecomparator, mirroring Moodle's own string.ContextLevel::Moduleenum used for context level. - PHPDoc on every class and public method — Class-level docblock on
MoodleLabel;@throwsnot applicable (no exceptions thrown publicly); method docblock onlabel()andresolveLabelContent(). - Laravel Pint — Run to zero violations before completion (Phase 5 of tasks).
Security
- No write access to Moodle tables —
MoodleLabelis 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 viaPluginfileUrlResolver, 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
ContextResolverInterfaceandPluginfileUrlResolverInterfaceare 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),
urlnull enforcement, metadata field correctness, and mixed module types in the same section. - Pre-existing tests remain green —
CourseModinfoTestandPageModuleContentTestupdated to include thelabeltable so the now-wider eager load does not break them.