page-module-content
CompletedGET /api/v1/courses/{id} endpoint.
Page-type modules now include a resolved name (from mdl_page.name)
and a full content block with @@PLUGINFILE@@ token resolution.
No new routes. Builds on the course-modinfo and moodle-file-system infrastructure.
5W Overview
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.
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'.
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.
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
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
Flowchart
Files Changed
Rules Applied
🏗️ Architecture
- No layer skipping — resolver chain threaded Controller → CourseResource → SectionResource → ModuleResource; no direct coupling between Controller and ModuleResource.
- Thin controllers —
CourseController::show()only callswithModuleContentResolvers()and the existing context try/catch block; no business logic added. - Read-only Moodle models —
MoodlePageextendsMoodleModel; save/delete throwLogicException. - Moodle table naming —
$table = 'page'(bare name); theDB_TABLE_PREFIXenv var applies automatically. - Shared infrastructure reuse —
ContextResolverInterfaceandPluginfileUrlResolverInterfacefromapp/Shared/Contractsused 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 access —
MoodlePageis strictly read-only; nocreate(),update(),delete()orsave()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
\Throwableand 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 pattern —
withContentResolvers(),withModuleContentResolvers()returnstaticfor 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_spacesfixed in 4 files. - PHPDoc on every public method — all new and modified public methods have concise docblocks with
@paramwhere non-obvious. - final classes — all classes are
finalby default (MoodlePage, resources, repository). - Collection over raw PHP —
collect(),->filter(),->map(),->values()used throughout; noarray_maporarray_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 insetUp(). - Laravel fakes —
Http::fake()andQueue::fake()used; real Moodle REST calls never made in tests. - Mock external resolvers —
ContextResolverInterfaceandPluginfileUrlResolverInterfacemocked withcreateMock()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 prevention —
CourseModinfoTest.phpupdated so all 9 pre-existing tests stay green after adding thepageeager-load.