Module Content Endpoint
Generated 2026-04-16 · Branch: module-impl
· 16 files changed (11 new / modified, 3 deleted, 2 renamed)
5W Analysis
Two-part change: (1) strip the content block from the existing
GET /api/v1/courses/{id} modinfo endpoint — all module types now return only
metadata + resolved name; and (2) add a dedicated endpoint
GET /api/v1/courses/{courseId}/modules/{moduleId} that returns full, type-specific
content (page, label, url) on demand with @@PLUGINFILE@@ token resolution.
Authenticated students protected by auth:sanctum + moodle.active
middleware. The repository layer enforces an additional enrollment check — a student can only
fetch modules from courses they are actively enrolled in. Returns 404 (not 403)
for non-existent modules, wrong course, or unenrolled access to prevent enumeration.
The modinfo endpoint was returning full page/label/url content for every module on every course load — a large, unused payload. API consumers need to lazy-load module content independently, enabling selective caching and reducing initial payload size. The dedicated endpoint follows the same resolver infrastructure already built for pluginfile tokens, just moved to the right abstraction boundary.
The new endpoint is triggered on demand — when a student navigates to a specific module.
The request lifecycle begins at the ShowModuleRequest FormRequest (auth + id
validation), flows through ModuleController → ModuleService → ModuleRepository,
and is formatted by ModuleDetailResource which applies pluginfile resolution
synchronously before returning the JSON response.
Entirely within app/Modules/Course/. New files: ModuleController,
ModuleService, ShowModuleDTO, ShowModuleRequest,
ModuleDetailResource, ModuleNotFoundException. Modified:
CourseController (resolver injections removed), ModuleResource
(content stripped, name only), SectionResource and CourseResource
(threading removed). Service binding added in AppServiceProvider.
ModuleRepository::findForStudent() queries course_modules scoped
by moduleId, courseId, visible=1, and a
whereHas enrollment chain into enrol → user_enrolments. It
eager-loads moduleType, page, label, url
relations. ModuleDetailResource inspects modname and builds a
type-specific content block, resolving @@PLUGINFILE@@ tokens
via PluginfileUrlResolverInterface + ContextResolverInterface.
Failures are caught silently — raw content is served without token replacement.
Sequence Diagram
(sanctum + moodle.active) participant Req as ShowModuleRequest participant Ctrl as ModuleController participant Svc as ModuleService participant Repo as ModuleRepository participant DB as MySQL
(Moodle DB) participant CR as ContextResolver participant PFR as PluginfileUrlResolver participant Res as ModuleDetailResource Browser->>MW: GET /api/v1/courses/{courseId}/modules/{moduleId} MW-->>Browser: 401 JSON (if unauthenticated) MW->>Req: Resolve ShowModuleRequest Req->>Req: authorize() → user != null Req->>Req: rules() → validate route params as integers Req->>Ctrl: show(request, courseId, moduleId) Ctrl->>Req: toDTO() → ShowModuleDTO(courseId, moduleId, studentId) Ctrl->>Svc: show(ShowModuleDTO) Svc->>Repo: findForStudent(ShowModuleDTO) Repo->>DB: SELECT course_modules WHERE id=moduleId AND course=courseId AND visible=1
+ whereHas enrol→user_enrolments (student enrolled + active) DB-->>Repo: MoodleCourseModule | null Repo->>DB: Eager-load: moduleType, page, label, url DB-->>Repo: Relations loaded Repo-->>Svc: MoodleCourseModule | null alt Module not found / not enrolled Svc->>Svc: throw ModuleNotFoundException Svc-->>Ctrl: ModuleNotFoundException Ctrl-->>Browser: 404 {success:false, code:3003} else Module found Svc-->>Ctrl: MoodleCourseModule Ctrl->>Res: new ModuleDetailResource(module) Ctrl->>Res: withResolvers(pluginfileResolver, contextResolver) Res->>Res: inspect modname (page | label | url | other) alt modname = page or label (needs pluginfile resolution) Res->>CR: resolve(ContextLevel::Module, cmId) CR->>DB: SELECT context WHERE contextlevel=70 AND instanceid=cmId DB-->>CR: contextId CR-->>Res: contextId Res->>PFR: resolve(html, contextId, component, filearea, itemId) PFR->>PFR: Replace @@PLUGINFILE@@ with portal signed URLs PFR-->>Res: resolved HTML else modname = url Res->>Res: build content block (no pluginfile needed) else unimplemented type Res->>Res: no content block added end Res-->>Browser: 200 {success:true, data:{id, instance, modname, name, url, indent, completion, content?}} end
Flowchart
Files Changed
Rules Applied
-
Architecture
Thin controllers (5–15 lines) —
ModuleController::show()is 12 lines: calls$request->toDTO(), delegates toModuleService, catches the domain exception, and wraps the result in a resource. Zero business logic lives in the controller. -
Architecture
Layer ordering enforced — The full Request → Controller → FormRequest → Service → Repository → Model chain is followed without skipping.
ModuleDetailResourceonly formats — it does not query the database directly. -
Architecture
DTOs between layers —
ShowModuleDTOis afinal readonlyclass with constructor-promoted typed properties. The controller never passes raw request scalars into the service; the DTO is the boundary object. -
Architecture
Interface binding in AppServiceProvider —
ModuleControllerdepends onModuleService(concrete) andPluginfileUrlResolverInterface/ContextResolverInterface(contracts).ModuleServicedepends onModuleRepositoryInterface. All bound inAppServiceProvider::register(). -
Architecture
Read-only Moodle models —
MoodleCourseModule,MoodlePage,MoodleLabel,MoodleUrlare accessed through read-only Eloquent models inapp/Shared/Models/. No writes to Moodle tables occur in this feature. -
Architecture
Versioned REST routes — New route registered under
/api/v1/courses/{courseId}/modules/{moduleId}using plural nouns, kebab-case, nested resource pattern. Namedapi.v1.courses.modules.showfollowing dot-notation convention. -
Security
Enrollment check in repository (not controller) —
ModuleRepository::findForStudent()performs awhereHas('course.enrols.userEnrolments')scoped query. A student cannot access modules from courses they are not enrolled in. The check cannot be bypassed by passing arbitrary IDs. -
Security
404 instead of 403 to prevent enumeration —
ModuleNotFoundException(code 3003) is returned regardless of whether the module doesn't exist, belongs to a different course, or the student is not enrolled. Attackers cannot distinguish valid module IDs from invalid ones. -
Security
All input validated in FormRequest —
ShowModuleRequestvalidatescourseIdandmoduleIdasintegerbefore they reach the controller. Route parameters are cast explicitly intoDTO()with(int). -
Security
@@PLUGINFILE@@ tokens resolved to portal-local signed URLs — Raw Moodle storage paths are never exposed in responses. Context resolution failures are caught silently via
catch (\Throwable)— raw HTML is returned, no internal error details are leaked. -
Coding Style
PHP 8.3 features —
final readonly class ShowModuleDTOwith constructor promotion.final classeverywhere.match-styleif/elseifchain inModuleDetailResource::toArray(). Typed properties, explicit return types, and strict comparison throughout. -
Coding Style
Laravel-first, no raw PHP —
Config::get()instead ofenv().Collectionchaining inSectionResource::buildModules().Http::fake()in tests.Schemafacade for test DB setup. Noarray_map, nostr_*functions. -
Coding Style
PHPDoc on every class and public method — All new classes have class-level docblocks. Every public method has a 1–3 line docblock.
@throwsannotations onModuleService::show()andModuleRepository::findForStudent()'s interface contract. -
Coding Style
Custom exception classes —
ModuleNotFoundException extends RuntimeExceptioncarries a typedreadonly CourseErrorCode $errorCodeproperty. No barethrow new \Exceptionanywhere. Exception is caught specifically in the controller. -
Testing
Feature tests for all HTTP scenarios —
ModuleContentEndpointTestcovers all 8 required test cases: 401 unauthenticated, 3× 404 (not found, wrong course, not enrolled), and 4× 200 (page, label, url, unimplemented type). Each test follows Arrange → Act → Assert with specificassertJsonPathassertions. -
Testing
RefreshDatabase + real DB — All feature tests use
RefreshDatabaseand hit the real SQLite test database via inlineSchema::create()calls. No mocking of Eloquent.Http::fake()intercepts Moodle event forwarding.Queue::fake()prevents async dispatch. -
Testing
Test method naming — All methods prefixed
test_it_in snake_case describing behavior, not implementation:test_it_returns_page_module_content(),test_it_returns_404_when_student_is_not_enrolled(). PHPUnit 12#[Test]attribute used. -
Testing
Old per-type test files deleted —
PageModuleContentTest.php,LabelModuleContentTest.php, andUrlModuleContentTest.phpwere deleted. Coverage is consolidated inModuleContentEndpointTest— no duplicate or stale tests remain. -
Git Workflow
Conventional commits on branch
module-impl— Changes follow the scoped commit format:feat(course),refactor(course),test(course). Implementation and tests are committed together. Documentation updated as a separate step.