Resource Module
Student File Resource — Show detail & view-tracking for Moodle's mod_resource activity type
2API Endpoints
18PHP Files
11Test Cases
6Architecture Layers
1Domain Event
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:
Shared model:
Shared infrastructure:
app/Modules/Resource/ — controllers, services, repositories, events, requests, resources, DTOs, enums, exceptions, routes.Shared model:
app/Shared/Models/MoodleResource.phpShared 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 } }
(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. ResourceFileResolveris a focused service with a single responsibility: resolve a content file into a signed URL.ResourceViewedevent is dispatched from the service layer, not the controller.
Architecture
Shared Database Rules
MoodleResourceis read-only — extendsMoodleModelwhich throwsLogicExceptiononsave()/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/withkebab-caseplural noun path segments. - Consistent envelope:
{ success, message, data, errors, code }viaApiResource. - 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
ResourceViewedextendsBaseEventwith full Moodle metadata (component, target, action, crud, edulevel, objectTable, contextLevel, contextInstanceId).- Event dispatched via
Event::dispatch()(Laravel facade) — not manual listener calls. ForwardEventToMoodlelistener runs on queue (async) — does not block HTTP response.- Uses
mod_resource\event\course_module_viewedMoodle 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. ResourceRepositoryvalidatesresource.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 ondisplayoptions. - 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
finalby default; DTOs arefinal readonlywith constructor promotion. - Int-backed enums (
ResourceDisplay,ResourceErrorCode) instead of magic integer constants. - Constructor injection everywhere — no
app()orresolve()in services/repositories. Event::dispatch()facade used in service (HTTP layer context).- Typed properties and explicit return types on every method; no
mixedtypes. - Custom domain exceptions per module; never catching
\Exceptiongenerically. - PHPDoc on every class and public method (brief, with
@throwsfor 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 viaDB::table()->insert()insetUp(). - 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. ResourceRepositoryInterfacebound toResourceRepositoryinAppServiceProvider— services depend on contracts.- Shared infrastructure (
ContextResolverInterface,PluginfileUrlResolverInterface,FilterPipelineInterface) injected via constructor — reuses shared layer without duplication. - Routes loaded from module-level
routes.phpregistered in mainroutes/api.php.