5W Analysis

📄
What
Expose Moodle's mod_resource file activity to the student portal. Returns resource metadata, a signed download URL for the attached file, filtered intro HTML with @@PLUGINFILE@@ tokens resolved, and deserialized display options. Also provides a view-tracking endpoint that dispatches a ResourceViewed domain event for Moodle completion.
💡
Why
File resources (PDFs, slides, documents) are the most common non-interactive content type in Moodle courses. Students need a unified portal endpoint to render them with the correct display mode and file metadata, while Moodle's completion system requires view events to mark activities as done for progress tracking.
👤
Who
Authenticated students enrolled in the course that owns the resource. Access is guarded by auth:sanctum + moodle.active middleware at the route level and by a runtime enrollment check (EnrolmentRepositoryInterface::isEnrolledInCourse) in the service layer.
When
Show runs on every page open — student navigates to a resource page. View fires once per student visit and is idempotent on the Moodle side (Moodle deduplicates view events). Both run synchronously in the HTTP response cycle; the Moodle event forwarding runs asynchronously on the queue.
📍
Where
Module: app/Modules/Resource/ — controllers, services, repositories, events, requests, resources, DTOs, enums, exceptions, routes.

Shared model: app/Shared/Models/MoodleResource.php
Shared infrastructure: ContextResolver, PluginfileUrlResolver, FilterPipeline, MoodleFileUrl, ForwardEventToMoodle
How
Request → FormRequest (DTO) → Service (enrollment guard → repository → context resolver → ResourceFileResolver → pluginfile resolver → filter pipeline) → ResourceResource JSON response. File URLs are signed via MoodleFileUrl::pluginfilePathUrl with the resource's revision as cache-busting parameter. Display options are PHP-unserialize'd with allowed_classes: false sandboxing.

Sequence Diagram

GET /api/v1/courses/{courseId}/resources/{resourceId} — Show Resource
sequenceDiagram autonumber actor Browser participant MW as Middleware
(auth:sanctum, moodle.active) participant Ctrl as ResourceController participant Req as ShowResourceRequest participant Svc as ResourceService participant EnrolRepo as EnrolmentRepository participant ResRepo as ResourceRepository participant DB as MySQL (Moodle DB) participant CtxRes as ContextResolver participant FileRes as ResourceFileResolver participant PluginRes as PluginfileUrlResolver participant Filter as FilterPipeline participant ResResource as ResourceResource Browser->>MW: GET /api/v1/courses/{courseId}/resources/{resourceId} MW->>MW: Verify Sanctum token → authenticate student MW->>MW: Check moodle.active MW->>Ctrl: Forward request Ctrl->>Req: Resolve ShowResourceRequest Req->>Req: authorize() → true (enrollment checked in service) Req->>Req: rules() → [] (path params only) Req->>Req: toDTO() → ShowResourceDTO(studentId, courseId, resourceId) Req->>Ctrl: Return DTO Ctrl->>Svc: show(ShowResourceDTO) Svc->>EnrolRepo: isEnrolledInCourse(studentId, courseId) EnrolRepo->>DB: SELECT from enrolment tables DB-->>EnrolRepo: enrolled = true EnrolRepo-->>Svc: true Svc->>ResRepo: findByCourseAndInstance(courseId, resourceId) ResRepo->>DB: SELECT resource.* + cm.id
JOIN course_modules
WHERE resource.id=? AND cm.course=?
AND cm.visible=1 DB-->>ResRepo: resource row + cm_id ResRepo-->>Svc: [MoodleResource, cmId] Svc->>CtxRes: resolve(ContextLevel::Module, cmId) CtxRes->>DB: SELECT id FROM context WHERE contextlevel=70, instanceid=cmId DB-->>CtxRes: contextId CtxRes-->>Svc: contextId Svc->>FileRes: resolve(contextId, resource.revision) FileRes->>DB: SELECT FROM files
WHERE contextid=? AND component='mod_resource'
AND filearea='content' AND filename!='.' AND filesize>0 DB-->>FileRes: file row FileRes->>FileRes: MoodleFileUrl::pluginfilePathUrl(...) FileRes-->>Svc: ResourceFileDTO(url, filename, mimetype, filesize, timemodified) Svc->>PluginRes: resolve(intro, contextId, 'mod_resource', 'intro', 0) PluginRes->>PluginRes: Replace @@PLUGINFILE@@ tokens with real URLs PluginRes-->>Svc: resolvedIntro Svc->>Filter: process(resolvedIntro, FilterContext) Filter->>Filter: Run filter pipeline (multilang, MathJax, urltolink...) Filter-->>Svc: filteredIntro Svc-->>Ctrl: [MoodleResource, cmId, ResourceFileDTO, filteredIntro] Ctrl->>ResResource: new ResourceResource(resource).withData(cmId, fileDto, filteredIntro) ResResource->>ResResource: toArray() — build JSON shape
parseDisplayOptions(resource.displayoptions) ResResource-->>Ctrl: JsonResponse 200 Ctrl-->>Browser: 200 { success, message, data: { id, name, intro, display, displayOptions, file{url,...}, timemodified } }
POST /api/v1/courses/{courseId}/resources/{resourceId}/view — Record View
sequenceDiagram autonumber actor Browser participant MW as Middleware participant Ctrl as ResourceController participant Req as ViewResourceRequest participant Svc as ResourceService participant EnrolRepo as EnrolmentRepository participant ResRepo as ResourceRepository participant DB as MySQL participant EventBus as Laravel Event Bus participant Queue as Queue Worker participant Moodle as Moodle Plugin (REST) Browser->>MW: POST /api/v1/courses/{courseId}/resources/{resourceId}/view MW->>MW: auth:sanctum + moodle.active checks MW->>Ctrl: Forward request Ctrl->>Req: Resolve ViewResourceRequest Req->>Req: toDTO() → ViewResourceDTO(studentId, courseId, resourceId) Req->>Ctrl: Return DTO Ctrl->>Svc: recordView(ViewResourceDTO) Svc->>EnrolRepo: isEnrolledInCourse(studentId, courseId) EnrolRepo->>DB: SELECT enrollment DB-->>EnrolRepo: enrolled = true EnrolRepo-->>Svc: true Svc->>ResRepo: findByCourseAndInstance(courseId, resourceId) ResRepo->>DB: SELECT resource + course_modules DB-->>ResRepo: [MoodleResource, cmId] ResRepo-->>Svc: [MoodleResource, cmId] Svc->>EventBus: Event::dispatch(new ResourceViewed(userId, resourceId, courseId, courseModuleId)) EventBus->>Queue: Push ForwardEventToMoodle job (async) EventBus-->>Svc: dispatched Svc-->>Ctrl: void Ctrl-->>Browser: 200 { success: true, message: "Resource view recorded.", data: null } Note over Queue,Moodle: Async — does not block HTTP response Queue->>Moodle: POST /local/flexievent/event.php (ResourceViewed payload) Moodle-->>Queue: 200 OK

Flowchart

Resource Show — Main Success & Error Paths
flowchart TD A([Browser: GET /api/v1/courses/courseId/resources/resourceId]) --> B{auth:sanctum\nmiddleware} B -->|Token invalid| E401[/"401 Unauthenticated"/] B -->|Token valid| C{moodle.active\nmiddleware} C -->|Moodle inactive| E403A[/"403 Forbidden"/] C -->|Active| D[ShowResourceRequest.toDTO] D --> E[ResourceService.show] E --> F{isEnrolledInCourse?} F -->|Not enrolled| E403B[/"403 ResourceNotEnrolled\ncode: 5002"/] F -->|Enrolled| G[ResourceRepository\nfindByCourseAndInstance] G --> H{Resource found\nin course?} H -->|Not found| E404A[/"404 ResourceNotFound\ncode: 5001"/] H -->|Found| I[ContextResolver\nresolve Module cmId] I --> J[ResourceFileResolver\nresolve contextId revision] J --> K{Content file\nexists in mdl_files?} K -->|No file| E404B[/"404 ResourceFileNotFound\ncode: 5003"/] K -->|File found| L[Build signed pluginfile URL\nMoodleFileUrl] L --> M[PluginfileUrlResolver\nresolve @@PLUGINFILE@@ in intro] M --> N[FilterPipeline\nprocess filtered intro] N --> O[ResourceResource.toArray\nparseDisplayOptions] O --> P[/"200 OK\n{id, name, intro, display,\ndisplayOptions, file{url,...}}"/] style E401 fill:#7f1d1d,stroke:#ef4444,color:#fecaca style E403A fill:#7c2d12,stroke:#f97316,color:#fed7aa style E403B fill:#7c2d12,stroke:#f97316,color:#fed7aa style E404A fill:#1e3a5f,stroke:#60a5fa,color:#bfdbfe style E404B fill:#1e3a5f,stroke:#60a5fa,color:#bfdbfe style P fill:#064e3b,stroke:#10b981,color:#a7f3d0 style A fill:#1e1b4b,stroke:#818cf8,color:#c7d2fe
Resource View — Main Success & Error Paths
flowchart TD A([Browser: POST /api/v1/courses/courseId/resources/resourceId/view]) --> B{auth:sanctum\nmiddleware} B -->|Token invalid| E401[/"401 Unauthenticated"/] B -->|Token valid| C[ViewResourceRequest.toDTO] C --> D[ResourceService.recordView] D --> E{isEnrolledInCourse?} E -->|Not enrolled| E403[/"403 ResourceNotEnrolled\ncode: 5002"/] E -->|Enrolled| F[ResourceRepository\nfindByCourseAndInstance] F --> G{Resource found?} G -->|Not found| E404[/"404 ResourceNotFound\ncode: 5001"/] G -->|Found| H[Event::dispatch\nResourceViewed] H --> I[ForwardEventToMoodle\npushed to queue — async] I --> J[/"200 OK\n{success: true, message: 'Resource view recorded.', data: null}"/] style E401 fill:#7f1d1d,stroke:#ef4444,color:#fecaca style E403 fill:#7c2d12,stroke:#f97316,color:#fed7aa style E404 fill:#1e3a5f,stroke:#60a5fa,color:#bfdbfe style J fill:#064e3b,stroke:#10b981,color:#a7f3d0 style A fill:#1e1b4b,stroke:#818cf8,color:#c7d2fe

Files Changed

File Path Layer Description
app/Shared/Models/MoodleResource.php Model Read-only Eloquent model for Moodle's mdl_resource table. Extends MoodleModel (throws on save/delete). Integer casts for display, revision, course, introformat, timemodified.
app/Modules/Resource/Controllers/ResourceController.php Controller Thin controller. show() delegates to service, catches domain exceptions, returns ResourceResource. view() delegates to service, returns plain success envelope.
app/Modules/Resource/Services/ResourceService.php Service Orchestrates show (enrollment check → repository → context resolver → file resolver → pluginfile resolver → filter pipeline) and recordView (enrollment check → repository → event dispatch).
app/Modules/Resource/Services/ResourceFileResolver.php Service Queries mdl_files for component=mod_resource, filearea=content. Builds a revision-stamped signed pluginfile URL via MoodleFileUrl. Returns ResourceFileDTO.
app/Modules/Resource/Repositories/ResourceRepositoryInterface.php Repository Contract for findByCourseAndInstance(courseId, resourceId): array{MoodleResource, int}.
app/Modules/Resource/Repositories/ResourceRepository.php Repository Queries mdl_resource joined to mdl_course_modules (by module name = 'resource'). Filters by visible=1 and deletioninprogress=0. Throws ResourceNotFoundException when not found.
app/Modules/Resource/Events/ResourceViewed.php Event Extends BaseEvent. Carries userId, resourceId, courseId, courseModuleId. Sets Moodle event metadata: mod_resource\event\course_module_viewed, crud=Read, edulevel=Participating.
app/Modules/Resource/Requests/ShowResourceRequest.php Request FormRequest for GET show. Extracts authenticated user + route params into ShowResourceDTO. Authorization delegated to service-layer enrollment check.
app/Modules/Resource/Requests/ViewResourceRequest.php Request FormRequest for POST view. Mirrors ShowResourceRequest, produces ViewResourceDTO.
app/Modules/Resource/Resources/ResourceResource.php Resource Extends ApiResource. withData() fluent setter for cmId, fileDto, filteredIntro. parseDisplayOptions() unserializes PHP-serialized string with allowed_classes: false sandbox.
app/Modules/Resource/Enums/ResourceDisplay.php Enum Int-backed enum mapping resource.display to Moodle's RESOURCELIB_DISPLAY_* constants: Auto(0), Embed(1), Frame(2), New(3), Download(4), Open(5), Popup(6).
app/Modules/Resource/Enums/ResourceErrorCode.php Enum Domain error codes: ResourceNotFound(5001), NotEnrolled(5002), FileNotFound(5003). Carried by exceptions and written to error response envelope.
app/Modules/Resource/Exceptions/ResourceNotFoundException.php Exception Thrown by repository when resource not found or belongs to a different course. Carries ResourceErrorCode::ResourceNotFound. Caught in controller → 404.
app/Modules/Resource/Exceptions/ResourceFileNotFoundException.php Exception Thrown by ResourceFileResolver when no content file exists in mdl_files. Carries ResourceErrorCode::FileNotFound. Caught in controller → 404.
app/Modules/Resource/Exceptions/ResourceNotEnrolledException.php Exception Thrown by service when student is not enrolled in the requested course. Carries ResourceErrorCode::NotEnrolled. Caught in controller → 403.
app/Modules/Resource/DTOs/ShowResourceDTO.php DTO final readonly DTO carrying studentId, courseId, resourceId for the show endpoint.
app/Modules/Resource/DTOs/ViewResourceDTO.php DTO final readonly DTO carrying studentId, courseId, resourceId for the view endpoint.
app/Modules/Resource/DTOs/ResourceFileDTO.php DTO final readonly DTO carrying url, filename, mimetype, filesize, timemodified — output of ResourceFileResolver.
app/Modules/Resource/routes.php Routes Defines two routes under v1/courses/{courseId}/resources/{resourceId} with auth:sanctum + moodle.active middleware. Named: api.v1.courses.resources.show and api.v1.courses.resources.view.
app/Providers/AppServiceProvider.php Config Modified to bind ResourceRepositoryInterface → ResourceRepository in the service container.
routes/api.php Routes Modified to load app/Modules/Resource/routes.php alongside the other module route files.
tests/Feature/Resource/ResourceShowTest.php Test 6 feature tests: happy path (200), resource not found (404/5001), not enrolled (403/5002), no file (404/5003), pluginfile token resolution, display options deserialization. Seeds Moodle tables via DB::table()->insert().
tests/Feature/Resource/ResourceViewTest.php Test 3 feature tests: event dispatched on success (200), 403 for unenrolled student, 404 for missing resource. Uses Event::fake() + assertDispatched.
tests/Unit/Resource/ResourceFileResolverTest.php Test 2 unit tests: resolver returns signed URL DTO when file exists; throws ResourceFileNotFoundException when no file found.
docs/openapi.yaml Docs Appended Resource tag + 2 paths (GET show, POST view) with schemas for ResourceResponse, ResourceFileObject, ResourceDisplayOptions. 200/401/403/404 responses documented.
docs/postman_collection.json Docs Added Resource folder with Show Resource (GET) and View Resource (POST) requests. Each includes Accept + Authorization headers, example responses for all documented status codes, and test scripts validating envelope shape.

Rules Applied

Architecture Layer Separation
  • Controller is strictly thin (5–15 lines per method) — delegates entirely to ResourceService.
  • All business logic (enrollment guard, context resolution, file resolution, filter pipeline) lives in ResourceService, not the controller.
  • Repository handles only DB queries (ResourceRepository::findByCourseAndInstance) — no business logic.
  • ResourceFileResolver is a focused service with a single responsibility: resolve a content file into a signed URL.
  • ResourceViewed event is dispatched from the service layer, not the controller.
Architecture Shared Database Rules
  • MoodleResource is read-only — extends MoodleModel which throws LogicException on save() / delete().
  • $table = 'resource' — bare Moodle table name; DB_TABLE_PREFIX applied automatically.
  • No migrations created — only reading existing Moodle tables.
  • All Moodle reads via dedicated Eloquent models in app/Shared/Models/.
  • No mass assignment on Moodle data; explicit column selection in queries.
Architecture API Design & Response Format
  • Routes under /api/v1/ with kebab-case plural noun path segments.
  • Consistent envelope: { success, message, data, errors, code } via ApiResource.
  • HTTP 200 for show/view success, 403 for enrollment, 404 for missing resource/file, 401 for unauthenticated.
  • Named routes: api.v1.courses.resources.show, api.v1.courses.resources.view.
  • Content filters wired: @@PLUGINFILE@@ resolved before filter pipeline runs (pluginfile → filters order).
Architecture Events System
  • ResourceViewed extends BaseEvent with full Moodle metadata (component, target, action, crud, edulevel, objectTable, contextLevel, contextInstanceId).
  • Event dispatched via Event::dispatch() (Laravel facade) — not manual listener calls.
  • ForwardEventToMoodle listener runs on queue (async) — does not block HTTP response.
  • Uses mod_resource\event\course_module_viewed Moodle event name for correct completion triggering.
Security Authorization & Input Protection
  • Every endpoint requires auth:sanctum — no anonymous access.
  • Enrollment verified in service (EnrolmentRepositoryInterface::isEnrolledInCourse) for both endpoints.
  • ResourceRepository validates resource.course = courseId — student cannot request a resource from a different course by guessing its ID.
  • PHP unserialize() called with ['allowed_classes' => false] to prevent object injection attacks on displayoptions.
  • All queries use Eloquent / Query Builder parameter binding — no raw SQL interpolation.
  • Domain error codes never expose internal table names or query details in HTTP responses.
Coding Style PHP 8.3 & Laravel-First
  • All classes are final by default; DTOs are final readonly with constructor promotion.
  • Int-backed enums (ResourceDisplay, ResourceErrorCode) instead of magic integer constants.
  • Constructor injection everywhere — no app() or resolve() in services/repositories.
  • Event::dispatch() facade used in service (HTTP layer context).
  • Typed properties and explicit return types on every method; no mixed types.
  • Custom domain exceptions per module; never catching \Exception generically.
  • PHPDoc on every class and public method (brief, with @throws for every exception).
  • declare(strict_types=1) in every file.
Testing TDD Workflow & Test Structure
  • Tests written before implementation (Red → Green → Refactor).
  • No Moodle*Factory.php — Moodle tables seeded via DB::table()->insert() in setUp().
  • AAA pattern (Arrange → Act → Assert) in every test method.
  • Method names follow test_it_ prefix convention with behavioral description.
  • Feature tests use RefreshDatabase — real database, no Eloquent mocking.
  • Moodle REST API calls mocked via Http::fake() in test setUp.
  • Event dispatch tested with Event::fake() + Event::assertDispatched(ResourceViewed::class).
  • All error paths (401/403/404) have dedicated test cases with specific error code assertions.
Architecture Module Self-Containment & DI
  • Module is fully self-contained in app/Modules/Resource/ — controller, service, repository, events, requests, resources, DTOs, enums, exceptions, routes all co-located.
  • ResourceRepositoryInterface bound to ResourceRepository in AppServiceProvider — services depend on contracts.
  • Shared infrastructure (ContextResolverInterface, PluginfileUrlResolverInterface, FilterPipelineInterface) injected via constructor — reuses shared layer without duplication.
  • Routes loaded from module-level routes.php registered in main routes/api.php.