1 5W Overview

📦 What

Tracks student module completion state in Moodle's course_modules_completion table. Supports two modes: manual — student POSTs a state value (0/1/2/3) via API; and automatic view — a completion record is written silently when a student fetches a module that has completionview=1. The existing GET module endpoint is extended to return completionstate in its response.

When

POST — triggers during a student-initiated HTTP request to POST /api/v1/courses/{courseId}/modules/{moduleId}/completion. Runs after auth middleware (auth:sanctum + moodle.active), FormRequest validation, and access control in ModuleService::show().

Auto-view — fires as a side-effect inside ModuleService::show() on every successful GET /api/v1/courses/{courseId}/modules/{moduleId} call when the module has completionview=1.

💡 Why

PM task I01 — surface per-module completion state to students and propagate state changes back to Moodle so that course-progress, badges, and grade-book integrations continue to function correctly. Moodle's own event system expects \core\event\course_module_completion_updated to fire on every state change; this feature queues that event via ForwardEventToMoodle on the moodle-events queue.

📍 Where

Primary home: app/Modules/Course/ — controller, service, repository, request, resource, DTO, event, exception, and enum live here.

Shared infrastructure: app/Shared/Models/MoodleCourseModuleCompletion.php (the only write-enabled Moodle model in this feature).

Modified neighbours: MoodleCourseModule (new completionRecords() relation), ModuleService, ModuleController, ModuleDetailResource, CourseRepository, and AppServiceProvider.

How

Manual completion (POST): MarkModuleCompleteRequest validates that state is a valid CompletionState enum value. CompletionController::store() delegates to ModuleService::show() for access control, then calls CompletionService::markComplete(). The service checks completion=1 (throws CompletionNotAllowedException → 422 if not), then calls CompletionRepository::upsert() which constructs the model manually and calls save() — bypassing updateOrCreate() to preserve enum casting. After the write, ModuleCompletionUpdated is dispatched; the ForwardEventToMoodle listener processes it asynchronously on the moodle-events queue.

Auto-view (GET side-effect): ModuleService::show() calls CompletionService::recordViewIfRequired() after every successful module retrieval. The method silently skips when completion=0, completionview≠1, or viewed is already 1. Any Throwable is caught and logged as a warning — it never blocks the GET response. When a view is recorded, ModuleCompletionUpdated is also dispatched.

GET response extension: ModuleController calls CompletionService::getForUser() and passes the result to ModuleDetailResource::withCompletion(). The resource renders completionstate as the enum integer value, or null when no record exists.

2 Sequence Diagram

POST /api/v1/courses/{courseId}/modules/{moduleId}/completion — Manual Completion
sequenceDiagram autonumber participant B as Browser participant MW as Middleware
(auth:sanctum + moodle.active) participant CC as CompletionController participant MRQ as MarkModuleCompleteRequest participant MS as ModuleService participant MR as ModuleRepository participant CS as CompletionService participant CR as CompletionRepository participant DB as MySQL
(course_modules_completion) participant EV as Event Bus participant Q as Queue
(moodle-events) participant MO as Moodle Plugin
(REST) B->>MW: POST /completion { state: 1 } MW->>CC: Authenticated student request CC->>MRQ: validate { state: enum(0-3) } MRQ-->>CC: MarkModuleCompleteDTO CC->>MS: show(ShowModuleDTO) MS->>MR: findForStudent(dto) MR->>DB: SELECT course_modules WHERE id=X AND enrolled DB-->>MR: MoodleCourseModule MR-->>MS: MoodleCourseModule MS->>CS: recordViewIfRequired(module, userId) Note over CS: completionview check — silent skip or write MS-->>CC: MoodleCourseModule CC->>CS: markComplete(dto, module) CS->>CS: check module.completion === 1 alt completion ≠ 1 CS-->>CC: throws CompletionNotAllowedException CC-->>B: 422 { code: 3004 } else completion === 1 CS->>CR: upsert(moduleId, userId, state) CR->>DB: SELECT existing record DB-->>CR: null or existing CR->>DB: INSERT / UPDATE (manual save()) DB-->>CR: MoodleCourseModuleCompletion CR-->>CS: [record, isNew] CS->>EV: dispatch(ModuleCompletionUpdated) CS-->>CC: MoodleCourseModuleCompletion CC-->>B: 200 CompletionStateResource EV->>Q: ForwardEventToMoodle (queued) Q->>MO: POST event payload end
GET /api/v1/courses/{courseId}/modules/{moduleId} — Extended with completionstate
sequenceDiagram autonumber participant B as Browser participant MC as ModuleController participant MS as ModuleService participant MR as ModuleRepository participant CS as CompletionService participant CR as CompletionRepository participant DB as MySQL participant EV as Event Bus participant Q as Queue B->>MC: GET /modules/{moduleId} MC->>MS: show(ShowModuleDTO) MS->>MR: findForStudent(dto) MR->>DB: SELECT course_modules + enrollment check DB-->>MR: MoodleCourseModule MR-->>MS: MoodleCourseModule MS->>CS: recordViewIfRequired(module, userId) CS->>CS: completion===0? → skip CS->>CS: completionview!==1? → skip CS->>CR: recordView(moduleId, userId) CR->>DB: SELECT existing alt viewed already 1 DB-->>CR: existing row CR-->>CS: null (no write needed) else viewed=0 or no record CR->>DB: INSERT / UPDATE viewed=1 DB-->>CR: MoodleCourseModuleCompletion CR-->>CS: record CS->>EV: dispatch(ModuleCompletionUpdated) EV->>Q: ForwardEventToMoodle (queued) end MS-->>MC: MoodleCourseModule MC->>CS: getForUser(moduleId, userId) CS->>CR: findForUser(moduleId, userId) CR->>DB: SELECT completion record DB-->>CR: record or null CR-->>CS: ?MoodleCourseModuleCompletion CS-->>MC: ?MoodleCourseModuleCompletion MC-->>B: 200 ModuleDetailResource { completionstate: 1|null }

3 Flowchart

POST /completion — Main success path, validation branches, and error paths
flowchart TD A([POST /api/v1/courses/courseId/modules/moduleId/completion]) --> B{auth:sanctum\nmoodle.active} B -->|401 Unauthenticated| ERR401([401 Unauthorized]) B -->|Authenticated| C[MarkModuleCompleteRequest] C --> D{state valid?\n0/1/2/3 enum} D -->|Invalid value| ERR422A([422 Validation Error]) D -->|Valid| E[Build MarkModuleCompleteDTO] E --> F[ModuleService::show] F --> G{Module exists?\nStudent enrolled?} G -->|No| ERR404([404 Not Found\ncode: 3003]) G -->|Yes| H[MoodleCourseModule loaded] H --> I{completionview === 1?} I -->|Yes| J[recordViewIfRequired - side-effect\nsilent, never throws] I -->|No| K J --> K[CompletionService::markComplete] K --> L{module.completion === 1?} L -->|0 - NONE or 2 - AUTO| ERR422B([422 Unprocessable\ncode: 3004\nManual completion not enabled]) L -->|1 - MANUAL| M[CompletionRepository::upsert] M --> N{Record exists?} N -->|No - first mark| O[new MoodleCourseModuleCompletion\nmanual construct + save] N -->|Yes - update| P[load existing\nupdate state + timemodified\nsave] O --> Q[Record saved to DB] P --> Q Q --> R[Event::dispatch\nModuleCompletionUpdated] R --> S[ForwardEventToMoodle\nmoodle-events queue - async] R --> T[CompletionStateResource] T --> SUCCESS([200 OK\ncoursemoduleid, userid,\ncompletionstate, viewed,\ntimemodified]) style ERR401 fill:#ef444420,stroke:#ef4444,color:#ef4444 style ERR404 fill:#ef444420,stroke:#ef4444,color:#ef4444 style ERR422A fill:#ef444420,stroke:#ef4444,color:#ef4444 style ERR422B fill:#ef444420,stroke:#ef4444,color:#ef4444 style SUCCESS fill:#10b98120,stroke:#10b981,color:#10b981 style S fill:#6366f120,stroke:#6366f1,color:#a5b4fc

4 Files Changed

● Created — 14 files ● Modified — 10 files
File Path Layer Description
app/Modules/Course/Enums/CompletionState.php Enum Defines the 4 Moodle completion states (0=Incomplete, 1=Complete, 2=CompletePass, 3=CompleteFail) with an isPassing() helper using match.
app/Shared/Models/MoodleCourseModuleCompletion.php Model Write-enabled Eloquent model for course_modules_completion. The only approved write-target Moodle model in this feature; $readOnly=false. Casts completionstate to the CompletionState enum.
app/Modules/Course/DTOs/MarkModuleCompleteDTO.php DTO final readonly DTO carrying courseId, moduleId, studentId, and CompletionState between request and service layers.
app/Modules/Course/Events/ModuleCompletionUpdated.php Event Domain event extending BaseEvent. Maps to Moodle's \core\event\course_module_completion_updated. Carries courseModuleId and CompletionState; sets other with both values for Moodle compatibility.
app/Modules/Course/Exceptions/CompletionNotAllowedException.php Exception Thrown by CompletionService when completion≠1. Carries CourseErrorCode::CompletionNotAllowed (3004) for structured API error responses.
app/Modules/Course/Repositories/CompletionRepositoryInterface.php Interface Contract defining findForUser, upsert, and recordView operations. Bound in AppServiceProvider to CompletionRepository.
app/Modules/Course/Repositories/CompletionRepository.php Repository Implements manual model construction + save() for both upsert and recordView. Deliberately avoids updateOrCreate() to preserve CompletionState enum casting. Skips write in recordView when viewed is already 1.
app/Modules/Course/Services/CompletionService.php Service Orchestrates manual completion (markComplete), view-based auto-completion (recordViewIfRequired), and record retrieval (getForUser). All event dispatches live here. recordViewIfRequired wraps writes in try/catch(\Throwable) to prevent blocking GET responses.
app/Modules/Course/Requests/MarkModuleCompleteRequest.php Request Validates that state is an integer and a valid CompletionState enum value using Illuminate\Validation\Rules\Enum. Builds MarkModuleCompleteDTO from validated data and route parameters.
app/Modules/Course/Resources/CompletionStateResource.php Resource Formats MoodleCourseModuleCompletion for the POST response envelope. Returns completionstate as the raw integer value of the enum.
app/Modules/Course/Controllers/CompletionController.php Controller Thin controller for POST /completion. Re-uses ModuleService::show() for access control, delegates to CompletionService::markComplete(), and maps exceptions to HTTP status codes (404/422).
database/factories/MoodleCourseModuleCompletionFactory.php Factory Eloquent factory for MoodleCourseModuleCompletion used in unit and feature tests. Provides default state values with CompletionState::Incomplete.
tests/Unit/Services/CompletionServiceTest.php Test 9 unit tests for CompletionService covering markComplete (happy path, throws on completion≠1), recordViewIfRequired (skip conditions, first view, repeated view, DB error resilience), and getForUser.
tests/Feature/Course/CompletionTest.php Test 13 feature tests for the full HTTP lifecycle: 401 unauthenticated, 404 not-found/not-enrolled, 422 invalid-state/disabled/automatic completion, create record, update record, toggle to incomplete, event dispatched, GET with null state, GET with existing state, auto-view recording.
app/Shared/Models/MoodleCourseModule.php Model Added completionRecords(): HasMany relationship to MoodleCourseModuleCompletion. Added completionview integer cast to existing $casts array.
app/Modules/Course/Enums/CourseErrorCode.php Enum Added CompletionNotAllowed = 3004 case to the error code enum used for structured API error responses.
app/Modules/Course/Services/ModuleService.php Service Injected CompletionService via constructor. Added call to recordViewIfRequired(module, studentId) after successful module retrieval in show().
app/Modules/Course/Controllers/ModuleController.php Controller Injected CompletionService. After loading the module, calls getForUser() and passes the completion record to ModuleDetailResource::withCompletion().
app/Modules/Course/Resources/ModuleDetailResource.php Resource Added withCompletion(?MoodleCourseModuleCompletion $record): static fluent setter. Added completionstate field to toArray(), rendered as null when no record exists.
app/Modules/Course/Repositories/CourseRepository.php Repository Refactored getProgressMap() to query MoodleCourseModuleCompletion model via Eloquent instead of a raw query, aligning with the new model structure.
app/Modules/Course/routes.php Route Added Route::post('/{courseId}/modules/{moduleId}/completion', [CompletionController::class, 'store']) inside the existing auth:sanctum + moodle.active middleware group.
app/Providers/AppServiceProvider.php Provider Registered CompletionRepositoryInterface → CompletionRepository binding and singleton binding for CompletionService in the service container.
tests/Feature/Course/ModuleContentEndpointTest.php Test Added course_modules_completion table schema to the test setup (setUp()) to prevent "table not found" failures when recordViewIfRequired fires during existing module tests.

5 Rules Applied

📐 Architecture
  • Strict layer flow: Route → Middleware → Controller → FormRequest → Service → Repository → Model.
  • Controller is thin (≤15 lines per method); all logic lives in CompletionService.
  • MoodleCourseModuleCompletion placed in app/Shared/Models/ as a dedicated Moodle-table model. Only this feature's approved write target has $readOnly=false; all other Moodle models remain read-only.
  • Event dispatched by the Service layer via Event::dispatch(); no event dispatch in the controller or repository.
  • ForwardEventToMoodle runs on the moodle-events queue (async) — event forwarding never blocks HTTP response.
  • DTOs (MarkModuleCompleteDTO) are final readonly with constructor-promoted typed properties.
  • Interface (CompletionRepositoryInterface) bound in AppServiceProvider; service depends on the contract, not the concrete class.
  • API versioned under /api/v1/; resource named with plural nouns and kebab-case.
🔒 Security
  • All input validation in MarkModuleCompleteRequest — no inline validation in the controller or service.
  • state validated with Illuminate\Validation\Rules\Enum(CompletionState::class) — no raw integer comparison accepted from the client.
  • Every endpoint protected by auth:sanctum + moodle.active middleware — not public by default.
  • Access control delegated to ModuleService::show() (enrollment check) — students cannot mark completion for modules they cannot access.
  • No raw SQL — all DB access via Eloquent with typed bindings.
  • Sensitive data not exposed; error responses use structured code/message without stack traces.
  • Rate limiting inherited from the throttle middleware applied globally to api routes.
✏ Coding Style
  • PHP 8.3 Enum (CompletionState) for all completion state values — no raw integer constants.
  • PHP 8.3 readonly properties on the DTO and event constructor arguments.
  • match expression used in CompletionState::isPassing() — no switch.
  • All classes are final by default.
  • Every class and public method has a concise PHPDoc block with @throws where applicable.
  • Laravel-first: Carbon::now()->getTimestamp() instead of time(); Event::dispatch() facade; Log::warning().
  • Named arguments used in Event::dispatch(new ModuleCompletionUpdated(userId: ..., objectId: ..., ...)) for clarity.
  • Constructor promotion in the DTO, event, service, and controller classes.
  • No magic numbers — completionview === 1 documented with a comment mapping to COMPLETION_TRACKING_* constants.
🧪 Testing
  • TDD workflow followed — tests written before implementation for each behaviour.
  • AAA (Arrange → Act → Assert) pattern in all 22 tests.
  • All test methods prefixed with test_it_ and named to describe the expected behaviour.
  • Feature tests use RefreshDatabase trait and hit the real (in-memory) database — no mocking of Eloquent.
  • External Moodle HTTP calls mocked with Http::fake(); queue faked with Queue::fake().
  • Event dispatch asserted with Event::fake() + Event::assertDispatched() with closure checking all relevant properties.
  • 100% coverage of event dispatching paths: manual completion and auto-view both verified.
  • Existing test suite (ModuleContentEndpointTest) extended with completion table schema to maintain test independence.