5W Overview

📦 What

Fetches mdl_page content for page-type course modules and surfaces it in the course modinfo API response. Resolves @@PLUGINFILE@@ tokens in both intro and content fields using existing shared resolver infrastructure. Non-page modules remain unchanged.

When

Triggered on every GET /api/v1/courses/{id} request by an authenticated student. Runs during resource serialization — after the repository eager-loads sections.modules.page — inside ModuleResource::toArray() when modname === 'page'.

🎯 Why

PM requirement: the student portal must render Page activity content inline. Previously, every module's name was null (name lives in plugin tables, not course_modules) and no content block existed for page modules, making it impossible to display page body text to students.

📍 Where

New: app/Shared/Models/MoodlePage.php
Modified: app/Modules/Course/Resources/ (ModuleResource, SectionResource, CourseResource), app/Modules/Course/Controllers/CourseController.php, app/Shared/Models/MoodleCourseModule.php, app/Modules/Course/Repositories/CourseRepository.php

⚙️ How

The controller calls withModuleContentResolvers($contextResolver) on CourseResource, which stores the context resolver. During serialization, CourseResource::toStructureArray() passes both the already-resolved pluginfileResolver and the stored contentContextResolver down the resource chain via SectionResource::withModuleContentResolvers()ModuleResource::withContentResolvers(). In ModuleResource::toArray(), when modname === 'page' and the eager-loaded $module->page relation is not null, the module's name is set from mdl_page.name and resolvePageContent() is called. That method resolves the module-level Moodle context ID, then replaces @@PLUGINFILE@@ tokens in both intro and content HTML fields. A \Throwable catch ensures graceful degradation if the context row is missing.

Sequence Diagram

sequenceDiagram participant B as Browser participant MW as Auth Middleware participant CC as CourseController participant CS as CourseService participant CR as CourseRepository participant DB as MySQL (Moodle DB) participant CoR as CourseResource participant SeR as SectionResource participant MoR as ModuleResource participant CtxR as ContextResolver participant PfR as PluginfileUrlResolver B->>MW: GET /api/v1/courses/{id} MW->>CC: authenticated request CC->>CS: show(ShowCourseDTO) CS->>CR: findForStudent(courseId, studentId) CR->>DB: SELECT course + enrols + sections + modules (with moduleType, page) DB-->>CR: MoodleCourse (with eager-loaded relations) CR-->>CS: MoodleCourse CS-->>CC: MoodleCourse CC->>CoR: new CourseResource(course).withStructure().withEnrollment().withModuleContentResolvers(contextResolver) CC->>CtxR: resolve(ContextLevel::Course, courseId) alt context found CtxR->>DB: SELECT context WHERE contextlevel=50 AND instanceid=courseId DB-->>CtxR: contextId CtxR-->>CC: contextId CC->>CoR: withPluginfileResolution(pluginfileResolver, contextId) else context missing CtxR-->>CC: throws Throwable Note over CC: pluginfileResolver stays null on CourseResource end CC->>CoR: toResponse(request) CoR->>SeR: SectionResource::collection(sections).each(withStructure + withModuleContentResolvers) loop each section SeR->>MoR: new ModuleResource(module).withContext(...).withContentResolvers(pluginfileResolver, contextResolver) MoR->>MoR: toArray(request) alt modname === 'page' && module->page !== null MoR->>MoR: set name = module->page->name alt pluginfileResolver !== null && contextResolver !== null MoR->>CtxR: resolve(ContextLevel::Module, cmId) alt module context found CtxR->>DB: SELECT context WHERE contextlevel=70 AND instanceid=cmId DB-->>CtxR: moduleContextId CtxR-->>MoR: moduleContextId MoR->>PfR: resolve(intro, moduleContextId, mod_page, intro, 0) PfR-->>MoR: resolved intro HTML MoR->>PfR: resolve(content, moduleContextId, mod_page, content, 0) PfR-->>MoR: resolved content HTML else context missing CtxR-->>MoR: throws Throwable Note over MoR: catch — serve raw @@PLUGINFILE@@ content end end MoR-->>SeR: array with name + content block else non-page module MoR-->>SeR: array without content key, name = null end end SeR-->>CoR: serialized sections array CoR-->>CC: JsonResponse (200) CC-->>B: JSON response with page content

Flowchart

flowchart TD A([GET /api/v1/courses/{id}]) --> B{Authenticated?} B -- No --> B1([401 Unauthenticated]) B -- Yes --> C[CourseService::show] C --> D{Course exists &\nstudent enrolled?} D -- No --> D1([404 Not Found]) D -- Yes --> E[CourseRepository::findForStudent\neager-load sections.modules.moduleType + page] E --> F[Build CourseResource\n.withStructure .withEnrollment\n.withModuleContentResolvers] F --> G{Resolve course context ID} G -- Success --> H[.withPluginfileResolution\npluginfileResolver, contextId] G -- Throwable --> H2[pluginfileResolver = null\nTokens left unreplaced at course level] H --> I[Serialize sections loop] H2 --> I I --> J[SectionResource.withModuleContentResolvers\npluginfileResolver, contentContextResolver] J --> K[ModuleResource.withContentResolvers\npluginfileResolver, contextResolver] K --> L{modname === 'page'\nAND page relation loaded?} L -- No --> M[name = null\nNo content key in output] L -- Yes --> N[name = mdl_page.name] N --> O{pluginfileResolver !== null\nAND contextResolver !== null?} O -- No --> P[content block with raw\n@@PLUGINFILE@@ tokens] O -- Yes --> Q{Resolve module context ID\nContextLevel::Module, cmId} Q -- Success --> R[Replace @@PLUGINFILE@@\nin intro with contextId/mod_page/intro/0\nin content with contextId/mod_page/content/0] Q -- Throwable --> S[Catch — serve raw\n@@PLUGINFILE@@ content\nno crash] R --> T[content block with resolved URLs] S --> T P --> U[Assemble module array] T --> U M --> U U --> V{More modules?} V -- Yes --> K V -- No --> W{More sections?} W -- Yes --> J W -- No --> X([200 OK — full modinfo JSON]) style A fill:#1a1d27,stroke:#818cf8,color:#e2e8f0 style B1 fill:#1a1d27,stroke:#ef4444,color:#ef4444 style D1 fill:#1a1d27,stroke:#ef4444,color:#ef4444 style X fill:#1a1d27,stroke:#10b981,color:#10b981 style S fill:#1a1d27,stroke:#f59e0b,color:#f59e0b style P fill:#1a1d27,stroke:#f59e0b,color:#f59e0b style H2 fill:#1a1d27,stroke:#f59e0b,color:#f59e0b

Files Changed

File Path Layer Status Description
app/Shared/Models/MoodlePage.php Model New Read-only Eloquent model for mdl_page. Casts intro/content format fields as integers. Extends MoodleModel — no write operations permitted.
app/Shared/Models/MoodleCourseModule.php Model Modified Added page(): HasOne relation — hasOne(MoodlePage::class, 'id', 'instance'). Returns null for non-page modules where no matching row exists in mdl_page.
app/Modules/Course/Repositories/CourseRepository.php Repository Modified Updated findForStudent() eager load: changed with(['moduleType']) to with(['moduleType', 'page']) to load page content in a single query.
app/Modules/Course/Resources/ModuleResource.php Resource Modified Added withContentResolvers() fluent method and private resolvePageContent(). In toArray(), conditionally sets name and appends content block for page modules only.
app/Modules/Course/Resources/SectionResource.php Resource Modified Added contentPluginfileResolver and contentContextResolver properties (prefixed to avoid collision with section-summary resolver). Added withModuleContentResolvers(). Chains withContentResolvers() on each ModuleResource in buildModules().
app/Modules/Course/Resources/CourseResource.php Resource Modified Added contentContextResolver property and withModuleContentResolvers(ContextResolverInterface). In toStructureArray(), passes pluginfileResolver + contentContextResolver to each SectionResource.
app/Modules/Course/Controllers/CourseController.php Controller Modified Added ->withModuleContentResolvers($this->contextResolver) call when building the response resource. Existing pluginfile resolution try/catch preserved — its resolver is re-used for module content.
tests/Feature/Course/PageModuleContentTest.php Test New 6 feature tests covering: page name/content block presence, non-page module exclusion, @@PLUGINFILE@@ token resolution in both intro and content fields, graceful degradation when context resolver throws, and metadata field correctness.
tests/Feature/Course/CourseModinfoTest.php Test Modified Added page table schema creation to setUp() — required for the eager-load of the page relation to succeed without a "table not found" error during existing tests.

Rules Applied

🏗️ Architecture

  • No layer skipping — resolver chain threaded Controller → CourseResource → SectionResource → ModuleResource; no direct coupling between Controller and ModuleResource.
  • Thin controllersCourseController::show() only calls withModuleContentResolvers() and the existing context try/catch block; no business logic added.
  • Read-only Moodle modelsMoodlePage extends MoodleModel; save/delete throw LogicException.
  • Moodle table naming$table = 'page' (bare name); the DB_TABLE_PREFIX env var applies automatically.
  • Shared infrastructure reuseContextResolverInterface and PluginfileUrlResolverInterface from app/Shared/Contracts used without duplication.
  • No new routes — enhancement to existing endpoint, consistent with the API contract decision to not proliferate endpoints for module sub-types.

🔒 Security

  • No write accessMoodlePage is strictly read-only; no create(), update(), delete() or save() calls.
  • No new auth surface — existing Sanctum guard and enrollment scope on findForStudent() enforce that a student can only access courses they are enrolled in.
  • Error details never leak — context resolution failures are caught with \Throwable and served as raw content; no stack traces or Moodle internals exposed in responses.
  • No raw SQL — all DB access via Eloquent eager-load relations; no interpolation of user input into queries.
  • Token replacement produces portal-local URLs — resolved URLs point to /pluginfile/ portal routes, not direct Moodle storage paths.

✍️ Coding Style

  • PHP 8.3 typed properties — all new properties (?PluginfileUrlResolverInterface, ?ContextResolverInterface) have explicit types.
  • Constructor injection — resolvers injected via controller constructor, not service location.
  • Fluent builder patternwithContentResolvers(), withModuleContentResolvers() return static for chaining.
  • Named arguments — used in $this->pluginfileResolver->resolve(content:, contextId:, component:, filearea:, itemId:) for clarity.
  • Laravel Pint enforced — zero violations after running formatter; binary_operator_spaces fixed in 4 files.
  • PHPDoc on every public method — all new and modified public methods have concise docblocks with @param where non-obvious.
  • final classes — all classes are final by default (MoodlePage, resources, repository).
  • Collection over raw PHPcollect(), ->filter(), ->map(), ->values() used throughout; no array_map or array_filter.

🧪 Testing

  • TDD workflow — 6 feature tests written before implementation code; all red initially.
  • AAA pattern — every test has explicit Arrange / Act / Assert blocks with comments.
  • RefreshDatabase — all tests use in-memory SQLite with RefreshDatabase; full schema created in setUp().
  • Laravel fakesHttp::fake() and Queue::fake() used; real Moodle REST calls never made in tests.
  • Mock external resolversContextResolverInterface and PluginfileUrlResolverInterface mocked with createMock() and bound via $this->app->instance().
  • Descriptive names — all test methods prefixed test_it_ and describe expected behavior, not implementation.
  • Edge cases covered — non-page module (no content key), context resolver throws (graceful degradation), intro token resolution, content token resolution, metadata field values.
  • Regression preventionCourseModinfoTest.php updated so all 9 pre-existing tests stay green after adding the page eager-load.