Forum Read Operations
GET
/api/v1/courses/{courseId}/forums
List visible forum instances in a course
GET
/api/v1/courses/{courseId}/forums/{forumId}/discussions
List paginated discussions with reply counts
GET
/api/v1/courses/{courseId}/forums/{forumId}/discussions/{discussionId}/posts
List posts with signed attachment URLs
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()viaEnrolmentRepositoryInterface::isEnrolledInCourse(). Unenrolled students get 403. - Forum visibility: Repository JOINs
mdl_course_modules+mdl_modules, filteringvisible=1andmodule.name='forum'. - Discussion visibility: Filtered by
timestart ≤ now()ANDtimeend > now()(0 = no limit) applied in bothlistForForum()and the discussion count map query. - Reply counts: Batch-loaded via
DiscussionRepository::getReplyCountMap()— counts posts withparent > 0(root post excluded) per discussion ID. - Post attachments: Service resolves the forum's
contextidonce viaMoodleContextResolver(24 h cache), then batch-fetches allmod_forum/attachmentfiles for the page's post IDs fromFileRepositoryInterface. Each file URL is HMAC-signed viaMoodleFileUrl::pluginfileUrl(). - Soft-deleted posts:
MoodleForumPostregisters a global scopedeleted=0viabooted(), so deleted posts are invisible at the model level. - Events:
ForumDiscussionListViewedandForumPostListViewedextendBaseEvent; dispatched synchronously but handled async byForwardEventToMoodlelistener on the queue. - Author enrichment: All repositories JOIN
mdl_userto selectfirstname,lastname, andpicture 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 viaEvent::dispatch()and forwarded async viaForwardEventToMoodlelistener 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 readonlywith constructor promotion - Constructor injection throughout; no
app()orresolve()in service/repository layer - Interfaces bound in AppServiceProvider; service depends on contracts not concretes
🔒 Security Rules
- Every endpoint protected by
auth:sanctummiddleware — 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
MoodleModelwhich 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
finalby 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,Carbonvia casts,Event::dispatch(),response()->json(),DB::table()Query Builder - Every class and public method has a concise PHPDoc block with
@throwsannotations where exceptions are raised - Explicit return types on every method; no
mixedtypes; 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
RefreshDatabasetrait used in all feature tests; tests use real DB not mocks- Moodle REST API calls mocked via
Http::fake(); event forwarding tested withEvent::fake() - Tests mirror app structure:
tests/Feature/Forum/matchesapp/Modules/Forum/ - 100% event dispatch coverage: ForumDiscussionListViewed and ForumPostListViewed both verified in PostListTest