Enrollment List
5W Analysis
Two read-only REST endpoints exposing a student's active course enrollments sourced directly from Moodle's database. GET /api/v1/enrollments returns a paginated list (minimal shape). GET /api/v1/enrollments/{id} returns a single enrollment with full course detail. No writes to any table.
Authenticated students only — protected by auth:sanctum and moodle.active middleware. Each student can only ever see their own enrollments; the userid scope is always bound to auth()->id(). Unauthenticated requests receive 401.
Triggered on every GET request to /api/v1/enrollments or /api/v1/enrollments/{id}. Phase 2 of the Enrollment module — the first set of Enrollment routes shipped to production. Runs entirely within the HTTP request lifecycle; no queue jobs or async operations are used (read-only feature).
Students need to discover which courses they are enrolled in without navigating Moodle's legacy UI. This feature bridges the gap by surfacing Moodle enrollment data through a clean, modern REST API consumed by the new student portal frontend. The shared-DB approach avoids data duplication and keeps Moodle as the source of truth.
Lives across two areas of the codebase:
app/Modules/Enrollment/— controller, service, repository, requests, resources, DTOs, enums, exceptions, routesapp/Shared/Models/— three read-only Moodle Eloquent models (MoodleUserEnrolment,MoodleEnrol,MoodleCourse)app/Shared/Enums/—EnrolmentStatus,EnrolMethod
Requests pass through ListEnrolmentsRequest (validates enrol_type via Rule::enum, per_page 1–100), get converted to a ListEnrolmentsDTO, and are delegated to EnrolmentService → EnrolmentRepository. The repository queries MoodleUserEnrolment with eager-loaded enrol.course, always scoped to userid = student, status = Active (0), course.visible = 1. An optional enrol_type filter applies a whereHas clause. Results are wrapped in EnrolmentResource — minimal shape for list, full shape via withDetail() for show. On 404, EnrolmentNotFoundException is thrown and returns a structured error envelope.
Sequence Diagram
(auth:sanctum + moodle.active) participant Ctrl as EnrolmentController participant FR as ListEnrolmentsRequest participant Svc as EnrolmentService participant Repo as EnrolmentRepository participant DB as MySQL
(Moodle Shared DB) Note over Browser,DB: GET /api/v1/enrollments?enrol_type=manual&per_page=15 Browser->>MW: GET /api/v1/enrollments MW-->>Browser: 401 Unauthenticated (no token) Note over Browser,DB: Authenticated Request Browser->>MW: GET /api/v1/enrollments (Bearer token) MW->>MW: Validate Sanctum token MW->>MW: Check Moodle user active (moodle.active) MW->>FR: Pass to FormRequest FR->>FR: authorize() → true FR->>FR: validate enrol_type ∈ EnrolMethod enum FR->>FR: validate per_page (int, 1–100) FR-->>Browser: 422 Validation Error (invalid enrol_type) Note over FR,Ctrl: Valid request FR->>Ctrl: index(ListEnrolmentsRequest $request) Ctrl->>FR: $request->toDTO() FR-->>Ctrl: ListEnrolmentsDTO(studentId, enrolType, perPage) Ctrl->>Svc: list(ListEnrolmentsDTO) Svc->>Repo: listForStudent(ListEnrolmentsDTO) Repo->>DB: SELECT ue.* FROM user_enrolments ue
JOIN enrol e ON ue.enrolid = e.id
JOIN course c ON e.courseid = c.id
WHERE ue.userid = :studentId
AND ue.status = 0 (Active)
AND e.status = 0
AND e.enrol = :enrolType (optional)
AND c.visible = 1
LIMIT :perPage DB-->>Repo: Paginated rows Repo->>DB: Eager load enrol + course relations DB-->>Repo: Related records Repo-->>Svc: LengthAwarePaginator Svc-->>Ctrl: LengthAwarePaginator Ctrl->>Ctrl: EnrolmentResource::collection(paginator) Ctrl-->>Browser: 200 JSON { success, message, data: [...], meta: {page,per_page,total} } Note over Browser,DB: GET /api/v1/enrollments/{id} Browser->>MW: GET /api/v1/enrollments/123 MW->>Ctrl: show(Request, id=123) Ctrl->>Ctrl: new ShowEnrolmentDTO(enrolmentId=123, studentId=auth) Ctrl->>Svc: show(ShowEnrolmentDTO) Svc->>Repo: findForStudent(123, studentId) Repo->>DB: SELECT * WHERE id=123 AND userid=:studentId AND status=0 AND visible=1 alt Found DB-->>Repo: Row + relations Repo-->>Svc: MoodleUserEnrolment Svc-->>Ctrl: MoodleUserEnrolment Ctrl->>Ctrl: new EnrolmentResource(e)->withDetail() Ctrl-->>Browser: 200 JSON { data: { ...full course fields } } else Not found / wrong student / suspended DB-->>Repo: Empty (ModelNotFoundException) Repo->>Repo: catch ModelNotFoundException Repo-->>Svc: throw EnrolmentNotFoundException Svc-->>Ctrl: throw EnrolmentNotFoundException Ctrl->>Ctrl: catch EnrolmentNotFoundException Ctrl-->>Browser: 404 { error: { code: 2001, message: "Enrollment not found." } } end
Flowchart
Files Changed
| File Path | Layer | Description |
|---|---|---|
| app/Shared/Models/MoodleUserEnrolment.php | Model | Read-only Eloquent model for Moodle's user_enrolments table. Casts status to EnrolmentStatus enum; casts four Unix timestamps. Defines enrol() and user() BelongsTo relationships. |
| app/Shared/Models/MoodleEnrol.php | Model | Read-only Eloquent model for Moodle's enrol table. Defines course() BelongsTo and userEnrolments() HasMany relationships. Carries the plugin type string in the enrol column. |
| app/Shared/Models/MoodleCourse.php | Model | Read-only Eloquent model for Moodle's course table. Casts visible to boolean and four date timestamps. Defines enrols() HasMany relationship to MoodleEnrol. |
| app/Shared/Enums/EnrolmentStatus.php | Enum | Int-backed enum representing Moodle's user_enrolments status. Active = 0, Suspended = 1. Provides label() method returning lowercase string for API responses via match expression. |
| app/Shared/Enums/EnrolMethod.php | Enum | String-backed enum of supported Moodle enrolment plugin types: Manual, Self, Cohort, Meta, Guest. Used for validation via Rule::enum(EnrolMethod::class). |
| app/Modules/Enrollment/DTOs/ListEnrolmentsDTO.php | DTO | Immutable final readonly DTO carrying studentId: int, enrolType: ?EnrolMethod, perPage: int from the form request to the service layer. |
| app/Modules/Enrollment/DTOs/ShowEnrolmentDTO.php | DTO | Immutable final readonly DTO carrying enrolmentId: int and studentId: int for the show endpoint. Built inline in the controller from $request->user()->id. |
| app/Modules/Enrollment/Enums/EnrolmentErrorCode.php | Enum | Int-backed enum of Enrollment module error codes. NotFound = 2001. Embedded in EnrolmentNotFoundException and surfaced in the error envelope's code field. |
| app/Modules/Enrollment/Exceptions/EnrolmentNotFoundException.php | Exception | Domain exception thrown when a user_enrolment record is not found, is suspended, belongs to another student, or is in a hidden course. Carries EnrolmentErrorCode::NotFound as a readonly property. |
| app/Modules/Enrollment/Repositories/EnrolmentRepositoryInterface.php | Interface | Contract defining listForStudent(ListEnrolmentsDTO): LengthAwarePaginator and findForStudent(int, int): MoodleUserEnrolment. Enables mocking in unit tests. |
| app/Modules/Enrollment/Repositories/EnrolmentRepository.php | Repository | Concrete implementation of the repository interface. Builds Eloquent queries against MoodleUserEnrolment with eager-loaded enrol.course. Applies student scope, active status, visible course, and optional enrol_type filter via whereHas. Catches ModelNotFoundException and re-throws as EnrolmentNotFoundException. |
| app/Modules/Enrollment/Services/EnrolmentService.php | Service | Thin business logic layer delegating to EnrolmentRepositoryInterface. list() returns the paginator; show() returns a single enrolment or lets EnrolmentNotFoundException propagate. Constructor injection of the interface. |
| app/Modules/Enrollment/Requests/ListEnrolmentsRequest.php | Request | FormRequest for GET /api/v1/enrollments. Validates enrol_type against Rule::enum(EnrolMethod::class) and per_page as int 1–100. Provides toDTO() factory method that constructs ListEnrolmentsDTO from validated input. |
| app/Modules/Enrollment/Resources/EnrolmentResource.php | Resource | Formats MoodleUserEnrolment for API responses. Default shape is minimal (id, enrol_type, status, time_start, time_end, course name/shortname). Calling withDetail() before response adds summary, category, format, start_date, end_date. Normalises Moodle's zero-sentinel Unix timestamps to null. |
| app/Modules/Enrollment/Controllers/EnrolmentController.php | Controller | Thin HTTP controller. index() delegates to service via DTO, returns resource collection. show() builds DTO inline, catches EnrolmentNotFoundException, returns structured 404 error via ApiResource::error() or full detail resource. |
| app/Modules/Enrollment/routes.php | Routes | Registers GET /api/v1/enrollments (index, named api.v1.enrollments.index) and GET /api/v1/enrollments/{id} (show, named api.v1.enrollments.show) under auth:sanctum + moodle.active middleware group. |
| app/Providers/AppServiceProvider.php | Provider | Binds EnrolmentRepositoryInterface → EnrolmentRepository and manually constructs EnrolmentService with constructor injection via the service container. |
| tests/Feature/Enrollment/EnrolmentTest.php | Test | 13 feature tests covering: paginated list, envelope structure, cross-student isolation, suspended enrollment exclusion, hidden course exclusion, enrol_type filtering, per_page pagination, 401 unauthenticated (list + show), detail shape, 404 for other student / nonexistent / suspended / hidden on show, zero-timestamp null normalisation. |
| tests/Unit/Services/EnrolmentServiceTest.php | Test | 4 unit tests for EnrolmentService using PHPUnit mocks. Verifies delegation to repository for list and show, exception propagation on not-found, and correct DTO field forwarding (studentId, perPage). |
| tests/Unit/Models/MoodleUserEnrolmentTest.php | Test | Unit tests verifying table name (user_enrolments), read-only guard (save/delete throw), relationships, and enum casts for MoodleUserEnrolment. |
| tests/Unit/Models/MoodleEnrolTest.php | Test | Unit tests verifying table name (enrol), read-only guard, and BelongsTo/HasMany relationship definitions on MoodleEnrol. |
| tests/Unit/Models/MoodleCourseTest.php | Test | Unit tests verifying table name (course), read-only guard, HasMany relationship to MoodleEnrol, and boolean/timestamp casts on MoodleCourse. |
Rules Applied
Strict Layer Separation — No Skipping
Every request flows Controller → FormRequest → Service → Repository → Model. The controller is 5–15 lines and delegates all logic. The service only orchestrates; no DB queries. The repository holds all Eloquent code.
Read-Only Moodle Models in app/Shared/Models/
MoodleUserEnrolment, MoodleEnrol, and MoodleCourse all extend MoodleReadModel which overrides save()/delete() to throw exceptions. Table names set to bare Moodle names without prefix — DB_TABLE_PREFIX applies automatically.
DTO Pattern Between Layers
ListEnrolmentsDTO and ShowEnrolmentDTO are final readonly classes with constructor-promoted typed properties. They are the exclusive data carriers between controller and service, eliminating raw array passing.
Module Self-Containment
All Enrollment-specific files live under app/Modules/Enrollment/. Only cross-cutting concerns (Moodle read models, shared enums) live under app/Shared/, in accordance with the module organisation rule.
Constructor Injection + Interface Binding
EnrolmentService depends on EnrolmentRepositoryInterface, not the concrete class. Binding is declared in AppServiceProvider. No app() calls or service location in business logic.
Standard API Response Envelope
All responses include success, message, data, errors, code, meta fields. Collections include paginator meta (current_page, per_page, total). Error responses use { error: { code, message } } shape via ApiResource::error().
API Versioning and Route Naming
Routes are under /api/v1/ as required. Named with dot notation: api.v1.enrollments.index and api.v1.enrollments.show. Resource uses plural noun (enrollments), no verbs.
Student Scope Always Applied — 404 Not 403
Every query is scoped with userid = auth()->id(). If an enrollment ID belongs to another student, the response is 404 (not 403) to prevent student ID enumeration. This is enforced at the repository level by combining id and userid conditions.
All Input Validation in FormRequest
ListEnrolmentsRequest validates enrol_type with Rule::enum(EnrolMethod::class) and per_page as integer 1–100. No inline validation in controllers or services. The authorize() method returns true — auth is handled by middleware.
No SQL Injection — Eloquent + Query Builder Only
All database access uses Eloquent's query builder with automatic parameter binding. No DB::raw(), no string interpolation into queries. The optional enrol_type filter uses ->where('enrol', $dto->enrolType->value) with full binding.
PHP 8.3 Features Throughout
Backed enums (EnrolmentStatus: int, EnrolMethod: string, EnrolmentErrorCode: int), readonly properties on all DTOs, constructor promotion, typed properties on every class, match in EnrolmentStatus::label(), first-class callables in closure chains (fn (Builder $q)).
Laravel First — No Raw PHP
Carbon::createFromTimestamp() instead of date(). Rule::enum() for enum validation. $request->integer('per_page', 15) instead of intval(). $request->filled() instead of manual null checks. Query Builder for all DB access.
final Classes, Typed Methods, PHPDoc on Every Public Member
Every class is final. Every method has explicit return types and typed parameters. Every class and public method has a PHPDoc block with @throws where exceptions are raised. No mixed types anywhere.
No Magic Strings — Enums for All Fixed Values
Enrolment status values (0/1) are never used as raw integers — always via EnrolmentStatus::Active->value. Plugin type strings are never hardcoded — always from EnrolMethod. Error codes are EnrolmentErrorCode::NotFound.
TDD — Red → Green → Refactor
Phases 1–3 in tasks.md show tests written before implementation. Failing feature tests in EnrolmentTest.php were committed before the scaffold (Phase 4) and implementation (Phase 5). Unit model tests written before models in Phase 1.
Feature Tests Hit Real Database (RefreshDatabase)
All 13 feature tests use RefreshDatabase and create Moodle-schema tables in setUp() via Schema::create(). No mocks for the database layer — real Eloquent queries run against SQLite in-memory. Http::fake() used only for external Moodle event forwarding calls.
Unit Tests Mock the Interface, Not the Concrete
EnrolmentServiceTest mocks EnrolmentRepositoryInterface with PHPUnit's createMock(). Service tests are fully isolated with zero DB access. Verifies delegation, exception propagation, and DTO field forwarding.
AAA Pattern + Descriptive test_it_ Naming
Every test method follows Arrange → Act → Assert with comment separators. Method names like test_it_excludes_hidden_courses() and test_it_returns_404_for_another_students_enrollment() describe behavior and expected outcome, not implementation details.