🗺 5W Overview

📌 What

Three read-only REST endpoints that expose Moodle forum data to the student portal:

  • List visible forum instances inside a course (with discussion counts)
  • List paginated discussions inside a forum, filtered by time visibility and enriched with reply counts
  • List paginated posts inside a discussion, including HMAC-signed attachment URLs from mdl_files

No write operations are in scope — I04 is read-only.

When

Triggered on each authenticated HTTP request:

  • Request hits route → middleware validates Sanctum token + Moodle active status
  • FormRequest validates route parameters
  • Service asserts enrollment synchronously
  • Repository queries Moodle tables (read-only)
  • Domain events (ForumDiscussionListViewed, ForumPostListViewed) dispatched async after response is built

Implemented after the MoodleFile module (prerequisite for attachment URLs).

💡 Why

Students need to read course forums from the new Laravel portal without Moodle's frontend. The existing Moodle admin/teacher UI remains unchanged. This feature bridges the student experience gap while maintaining Moodle event parity — every view is forwarded to Moodle as a native event via the ForwardEventToMoodle listener, keeping audit trails intact.

📁 Where
  • Module: app/Modules/Forum/
  • Shared models: app/Shared/Models/MoodleForum*
  • Moodle tables: mdl_forum, mdl_forum_discussions, mdl_forum_posts, mdl_files
  • Routes: app/Modules/Forum/routes.php
  • Factories: database/factories/MoodleForum*Factory.php
  • Tests: tests/Feature/Forum/
⚙️ How
  • Enrollment guard: Every endpoint calls ForumService::assertEnrolled() via EnrolmentRepositoryInterface::isEnrolledInCourse(). Unenrolled students get 403.
  • Forum visibility: Repository JOINs mdl_course_modules + mdl_modules, filtering visible=1 and module.name='forum'.
  • Discussion visibility: Filtered by timestart ≤ now() AND timeend > now() (0 = no limit) applied in both listForForum() and the discussion count map query.
  • Reply counts: Batch-loaded via DiscussionRepository::getReplyCountMap() — counts posts with parent > 0 (root post excluded) per discussion ID.
  • Post attachments: Service resolves the forum's contextid once via MoodleContextResolver (24 h cache), then batch-fetches all mod_forum/attachment files for the page's post IDs from FileRepositoryInterface. Each file URL is HMAC-signed via MoodleFileUrl::pluginfileUrl().
  • Soft-deleted posts: MoodleForumPost registers a global scope deleted=0 via booted(), so deleted posts are invisible at the model level.
  • Events: ForumDiscussionListViewed and ForumPostListViewed extend BaseEvent; dispatched synchronously but handled async by ForwardEventToMoodle listener on the queue.
  • Author enrichment: All repositories JOIN mdl_user to select firstname, lastname, and picture itemid — no N+1 queries.

🔄 Sequence Diagram

GET /forums — List Forum Instances
sequenceDiagram autonumber participant B as Browser participant R as Route/Middleware participant FR as ListForumsRequest participant C as ForumController participant S as ForumService participant ER as EnrolmentRepository participant FRep as ForumRepository participant DB as Moodle DB B->>R: GET /api/v1/courses/{courseId}/forums R->>R: auth:sanctum + moodle.active alt Unauthenticated R-->>B: 401 Unauthorized end R->>FR: validate(courseId, per_page?) alt Validation fails FR-->>B: 422 Unprocessable end FR->>C: index(request, courseId) C->>S: list(ListForumsDTO) S->>ER: isEnrolledInCourse(studentId, courseId) ER->>DB: SELECT mdl_enrol + mdl_user_enrolments DB-->>ER: enrolled? alt Not enrolled ER-->>S: false S-->>C: throws ForumAccessDeniedException C-->>B: 403 Forbidden end S->>FRep: listForCourse(dto) FRep->>DB: SELECT forum.* JOIN course_modules WHERE visible=1 DB-->>FRep: LengthAwarePaginator FRep-->>S: paginator S-->>C: paginator C->>S: getDiscussionCountMap(forumIds) S->>FRep: getDiscussionCountMap(forumIds) FRep->>DB: SELECT COUNT(*) FROM forum_discussions GROUP BY forum DB-->>FRep: map[forumId]count FRep-->>S: discussionCountMap S-->>C: discussionCountMap C-->>B: 200 ForumCollection (paginated)
GET /discussions — List Discussions with Reply Counts
sequenceDiagram autonumber participant B as Browser participant R as Route/Middleware participant C as DiscussionController participant S as ForumService participant ER as EnrolmentRepository participant FRep as ForumRepository participant DRep as DiscussionRepository participant DB as Moodle DB participant Q as Queue B->>R: GET /api/v1/courses/{courseId}/forums/{forumId}/discussions R->>R: auth:sanctum + moodle.active R->>C: index(request, courseId, forumId) C->>S: listDiscussions(ListDiscussionsDTO) S->>ER: isEnrolledInCourse(studentId, courseId) alt Not enrolled S-->>C: throws ForumAccessDeniedException C-->>B: 403 Forbidden end S->>FRep: findForCourse(forumId, courseId) alt Forum not found / invisible FRep-->>S: throws ForumNotFoundException S-->>C: propagates C-->>B: 404 Not Found end FRep->>DB: SELECT forum.* JOIN course_modules WHERE visible=1 DB-->>FRep: MoodleForum S->>DRep: listForForum(dto) DRep->>DB: SELECT forum_discussions JOIN user WHERE timestart/timeend OK ORDER BY pinned DESC DB-->>DRep: LengthAwarePaginator DRep-->>S: paginator S->>Q: dispatch ForumDiscussionListViewed (async) S-->>C: paginator C->>S: getReplyCountMap(discussionIds) S->>DRep: getReplyCountMap(ids) DRep->>DB: COUNT forum_posts WHERE parent>0 GROUP BY discussion DB-->>DRep: map[discussionId]count DRep-->>C: replyCountMap C-->>B: 200 DiscussionCollection (paginated + reply counts)
GET /posts — List Posts with Attachment URLs
sequenceDiagram autonumber participant B as Browser participant R as Route/Middleware participant C as PostController participant S as ForumService participant ER as EnrolmentRepository participant FRep as ForumRepository participant DRep as DiscussionRepository participant PRep as PostRepository participant CR as ContextResolver participant FR2 as FileRepository participant DB as Moodle DB participant Q as Queue B->>R: GET /api/v1/courses/{courseId}/forums/{forumId}/discussions/{discussionId}/posts R->>R: auth:sanctum + moodle.active R->>C: index(request, courseId, forumId, discussionId) C->>S: listPosts(ListPostsDTO) S->>ER: isEnrolledInCourse(studentId, courseId) alt Not enrolled C-->>B: 403 Forbidden end S->>FRep: findForCourse(forumId, courseId) alt Forum not found C-->>B: 404 Forum Not Found end S->>DRep: findForForum(discussionId, forumId) alt Discussion not found C-->>B: 404 Discussion Not Found end S->>PRep: listForDiscussion(dto) PRep->>DB: SELECT forum_posts JOIN user WHERE deleted=0 ORDER BY created ASC DB-->>PRep: LengthAwarePaginator PRep-->>S: paginator S->>Q: dispatch ForumPostListViewed (async) S-->>C: paginator C->>S: getPostAttachmentsMap(postIds, forumId, courseId) S->>FRep: getCourseModuleId(forumId, courseId) FRep->>DB: SELECT cm.id FROM course_modules DB-->>FRep: courseModuleId S->>CR: resolve(Module, courseModuleId) CR->>DB: SELECT id FROM context WHERE contextlevel=70 Note over CR: Cached 24h DB-->>CR: contextId S->>FR2: findAllByContextIds([contextId], mod_forum, attachment) FR2->>DB: SELECT * FROM mdl_files WHERE component=mod_forum filearea=attachment DB-->>FR2: Collection of MoodleFile S->>S: groupBy(itemid=postId), filter to page postIds S->>S: attachmentUrl(file) → MoodleFileUrl::pluginfileUrl() → HMAC signed URL C-->>B: 200 PostCollection (paginated + signed URLs)

📊 Flowchart — Request Decision Paths

All three endpoints share this authorization pattern; the posts path adds attachment resolution
flowchart TD A([HTTP Request]) --> B{auth:sanctum\nmoodle.active} B -->|Invalid token| Z1([401 Unauthorized]) B -->|Valid| C[FormRequest validates\nroute params] C -->|Invalid params| Z2([422 Validation Error]) C -->|Valid| D[Service::assertEnrolled\nstudentId + courseId] D -->|Not enrolled| Z3([403 Forbidden]) D -->|Enrolled| E{Which endpoint?} E -->|GET /forums| F[ForumRepository\nlistForCourse\nJOIN course_modules\nvisible=1] F --> F1[getDiscussionCountMap\nbatch COUNT visible discussions] F1 --> F2([200 ForumCollection\npaginated]) E -->|GET /discussions| G[ForumRepository\nfindForCourse\nverify forum visible] G -->|Not found| Z4([404 Forum Not Found]) G -->|Found| H[DiscussionRepository\nlistForForum\ntimestart+timeend filter\nJOIN user for author] H --> H1[dispatch ForumDiscussionListViewed\nasync on queue] H1 --> H2[getReplyCountMap\nCOUNT posts WHERE parent>0] H2 --> H3([200 DiscussionCollection\nwith reply counts]) E -->|GET /posts| I[ForumRepository\nfindForCourse] I -->|Not found| Z5([404 Forum Not Found]) I -->|Found| J[DiscussionRepository\nfindForForum] J -->|Not found| Z6([404 Discussion Not Found]) J -->|Found| K[PostRepository\nlistForDiscussion\nglobal scope deleted=0\nJOIN user for author] K --> K1[dispatch ForumPostListViewed\nasync on queue] K1 --> L[getPostAttachmentsMap\nresolve contextId via cache] L --> M[FileRepository\nbatch-fetch mod_forum/attachment\nfrom mdl_files] M --> N[groupBy postId\nMoodleFileUrl::pluginfileUrl\nHMAC-signed URL per file] N --> O([200 PostCollection\nwith signed attachment URLs]) style Z1 fill:#7f1d1d,color:#fca5a5,stroke:#ef4444 style Z2 fill:#7f1d1d,color:#fca5a5,stroke:#ef4444 style Z3 fill:#7f1d1d,color:#fca5a5,stroke:#ef4444 style Z4 fill:#7f1d1d,color:#fca5a5,stroke:#ef4444 style Z5 fill:#7f1d1d,color:#fca5a5,stroke:#ef4444 style Z6 fill:#7f1d1d,color:#fca5a5,stroke:#ef4444 style F2 fill:#064e3b,color:#6ee7b7,stroke:#10b981 style H3 fill:#064e3b,color:#6ee7b7,stroke:#10b981 style O fill:#064e3b,color:#6ee7b7,stroke:#10b981

📂 Files Changed

File Path Layer Description
app/Modules/Forum/Controllers/ForumController.php Controller Handles GET /forums; delegates to ForumService, builds ForumCollection with discussion count map
app/Modules/Forum/Controllers/DiscussionController.php Controller Handles GET /discussions; catches ForumAccessDeniedException + ForumNotFoundException; enriches with reply count map
app/Modules/Forum/Controllers/PostController.php Controller Handles GET /posts; catches three domain exceptions; resolves attachments map and passes URL resolver to PostCollection
app/Modules/Forum/Services/ForumService.php Service All forum business logic: enrollment assertion, forum visibility check, discussion/post listing, attachment context resolution, event dispatch
app/Modules/Forum/Repositories/ForumRepositoryInterface.php Repository Contract: listForCourse, findForCourse, getCourseModuleId, getDiscussionCountMap
app/Modules/Forum/Repositories/ForumRepository.php Repository Implements ForumRepositoryInterface; JOINs course_modules + modules; filters visible=1 and module.name='forum'
app/Modules/Forum/Repositories/DiscussionRepositoryInterface.php Repository Contract: listForForum, findForForum, getReplyCountMap
app/Modules/Forum/Repositories/DiscussionRepository.php Repository Filters discussions by timestart/timeend; JOINs user for author fullname; counts replies (parent > 0)
app/Modules/Forum/Repositories/PostRepositoryInterface.php Repository Contract: listForDiscussion
app/Modules/Forum/Repositories/PostRepository.php Repository Relies on MoodleForumPost global scope (deleted=0); JOINs user for author name + picture; orders by created ASC
app/Modules/Forum/Events/ForumDiscussionListViewed.php Event Extends BaseEvent; component=mod_forum, action=viewed, target=discussion; carries courseModuleId for Moodle context
app/Modules/Forum/Events/ForumPostListViewed.php Event Extends BaseEvent; component=mod_forum, action=viewed, target=post; objectTable=forum_discussions; carries courseModuleId
app/Modules/Forum/Requests/ListForumsRequest.php Request Validates courseId exists in mdl_course; provides toDTO() producing ListForumsDTO with authenticated studentId
app/Modules/Forum/Requests/ListDiscussionsRequest.php Request Validates courseId, forumId integers; per_page 1–100 optional; toDTO(courseId, forumId)
app/Modules/Forum/Requests/ListPostsRequest.php Request Validates courseId, forumId, discussionId integers; per_page optional; toDTO(courseId, forumId, discussionId)
app/Modules/Forum/Resources/ForumResource.php Resource Transforms MoodleForum to JSON: id, courseModuleId, name, type, intro, discussionCount (from injected map), maxAttachments, maxBytes
app/Modules/Forum/Resources/ForumCollection.php Resource Paginated collection; holds discussionCountMap; distributes count per ForumResource
app/Modules/Forum/Resources/DiscussionResource.php Resource Transforms MoodleForumDiscussion: id, name, author{id, fullName}, firstPostId, pinned, locked, replyCount, timeModified
app/Modules/Forum/Resources/DiscussionCollection.php Resource Paginated collection; holds replyCountMap; distributes count per DiscussionResource
app/Modules/Forum/Resources/PostResource.php Resource Transforms MoodleForumPost: id, parentId, author{id, fullName, avatarUrl}, subject, message, created, modified, attachments[] with signed URL
app/Modules/Forum/Resources/PostCollection.php Resource Paginated collection; holds attachmentsMap keyed by postId and a URL resolver closure; distributes to each PostResource
app/Modules/Forum/DTOs/ListForumsDTO.php DTO final readonly: studentId, courseId, perPage=15
app/Modules/Forum/DTOs/ListDiscussionsDTO.php DTO final readonly: studentId, courseId, forumId, perPage=15
app/Modules/Forum/DTOs/ListPostsDTO.php DTO final readonly: studentId, courseId, forumId, discussionId, perPage=15
app/Modules/Forum/Exceptions/ForumNotFoundException.php Exception Thrown when forum not found in course or not visible; maps to 404
app/Modules/Forum/Exceptions/DiscussionNotFoundException.php Exception Thrown when discussion not found in forum; maps to 404
app/Modules/Forum/Exceptions/ForumAccessDeniedException.php Exception Thrown when student is not enrolled in the course; maps to 403
app/Modules/Forum/routes.php Routes Prefix v1/courses/{courseId}/forums; middleware auth:sanctum + moodle.active; 3 named GET routes
app/Shared/Models/MoodleForum.php Model Read-only Eloquent model for mdl_forum; casts timemodified; hasMany MoodleForumDiscussion via 'forum' FK
app/Shared/Models/MoodleForumDiscussion.php Model Read-only model for mdl_forum_discussions; casts timemodified/timestart/timeend; hasMany MoodleForumPost
app/Shared/Models/MoodleForumPost.php Model Read-only model for mdl_forum_posts; global scope via booted() filtering deleted=0; casts created/modified
database/factories/MoodleForumFactory.php Factory Test factory for MoodleForum — TDD prerequisite for feature tests
database/factories/MoodleForumDiscussionFactory.php Factory Test factory for MoodleForumDiscussion; supports timestart/timeend states for visibility tests
database/factories/MoodleForumPostFactory.php Factory Test factory for MoodleForumPost; supports deleted state for soft-delete exclusion tests
tests/Feature/Forum/ForumListTest.php Test Happy path, 401 unauth, 403 not enrolled, hidden course module not visible
tests/Feature/Forum/DiscussionListTest.php Test Happy path with reply counts, timestart/timeend filtering, 404 unknown forum, 403 not enrolled
tests/Feature/Forum/PostListTest.php Test Happy path with signed attachment URLs, deleted post exclusion, 404 unknown discussion, event dispatch verification

📏 Rules Applied

🏗 Architecture Rules

  • Full layer chain enforced: Route → Middleware → Controller → FormRequest → Service → Repository → Model — no layer skipped
  • Controllers are thin (≤15 lines of logic); all business logic lives in ForumService
  • Self-contained module: all controllers, services, repositories, events, DTOs, exceptions, and requests inside app/Modules/Forum/
  • Shared cross-cutting models (MoodleForum, MoodleForumDiscussion, MoodleForumPost) live in app/Shared/Models/
  • Moodle tables accessed exclusively through read-only Eloquent models extending MoodleModel — no direct DB writes
  • Domain events extend BaseEvent; dispatched via Event::dispatch() and forwarded async via ForwardEventToMoodle listener on the queue
  • API versioned under /api/v1/; nested resource URIs for relationships (forums/{id}/discussions/{id}/posts)
  • DTOs passed between controller → service → repository; final readonly with constructor promotion
  • Constructor injection throughout; no app() or resolve() in service/repository layer
  • Interfaces bound in AppServiceProvider; service depends on contracts not concretes

🔒 Security Rules

  • Every endpoint protected by auth:sanctum middleware — no public endpoints
  • Enrollment authorization checked at the service layer on every request via EnrolmentRepositoryInterface
  • Students can only access forums/discussions/posts in courses they are enrolled in (own-data access control)
  • All route parameters validated in dedicated FormRequest classes (courseId, forumId, discussionId as integers; per_page bounded 1–100)
  • Moodle table models are read-only — all three new shared models extend MoodleModel which overrides save/delete to throw exceptions
  • No raw SQL strings — all queries use Eloquent Query Builder with parameter binding
  • Attachment URLs are HMAC-signed (1-hour expiry) via MoodleFileUrl::pluginfileUrl() — not bare Moodle URLs
  • Error responses for 403/404 use generic domain messages; no stack traces or internal paths exposed
  • All API endpoints under rate-limiting middleware inherited from the global route group

💻 Coding Style Rules

  • All classes declared final by default (ForumService, ForumController, repositories, models, events, DTOs, exceptions)
  • PHP 8.3 features used: readonly properties on DTOs and events, constructor promotion, match expressions, named arguments (postIds:, forumId:, etc.), first-class callable syntax ($this->service->attachmentUrl(...))
  • Laravel-first: Collection::map/filter/groupBy, Carbon via casts, Event::dispatch(), response()->json(), DB::table() Query Builder
  • Every class and public method has a concise PHPDoc block with @throws annotations where exceptions are raised
  • Explicit return types on every method; no mixed types; strict comparison (===) throughout
  • Named route conventions: api.v1.forums.index, api.v1.forums.discussions.index, api.v1.forums.discussions.posts.index
  • Kebab-case URIs; plural nouns; nested for relationships
  • No magic strings: Moodle component/filearea strings are domain-specific constants used in one place per class
  • declare(strict_types=1) at the top of every PHP file

🧪 Testing Rules

  • TDD workflow: factories created before implementation (Phase 2 precedes Phase 3+)
  • Feature tests cover: happy path, 401 unauthenticated, 403 not enrolled, 404 not found, discussion time-visibility filtering, deleted post exclusion, event dispatch verification, attachment URL format
  • Test methods use test_it_ prefix with descriptive behavior names (e.g., test_it_returns_403_when_student_not_enrolled)
  • AAA pattern (Arrange → Act → Assert) in every test method
  • Factories for all three new Moodle models: MoodleForumFactory, MoodleForumDiscussionFactory, MoodleForumPostFactory
  • RefreshDatabase trait used in all feature tests; tests use real DB not mocks
  • Moodle REST API calls mocked via Http::fake(); event forwarding tested with Event::fake()
  • Tests mirror app structure: tests/Feature/Forum/ matches app/Modules/Forum/
  • 100% event dispatch coverage: ForumDiscussionListViewed and ForumPostListViewed both verified in PostListTest