5W Analysis

📦
What

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.

👤
Who

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.

When

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).

💡
Why

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.

📍
Where

Lives across two areas of the codebase:

  • app/Modules/Enrollment/ — controller, service, repository, requests, resources, DTOs, enums, exceptions, routes
  • app/Shared/Models/ — three read-only Moodle Eloquent models (MoodleUserEnrolment, MoodleEnrol, MoodleCourse)
  • app/Shared/Enums/EnrolmentStatus, EnrolMethod
How

Requests pass through ListEnrolmentsRequest (validates enrol_type via Rule::enum, per_page 1–100), get converted to a ListEnrolmentsDTO, and are delegated to EnrolmentServiceEnrolmentRepository. 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

Full HTTP Request Lifecycle
sequenceDiagram autonumber actor Browser participant MW as Middleware
(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

GET /api/v1/enrollments — List Endpoint
flowchart TD A([HTTP GET /api/v1/enrollments]) --> B{Sanctum token\nvalid?} B -- No --> C([401 Unauthenticated]) B -- Yes --> D{Moodle user\nactive?} D -- No --> E([401 / Inactive]) D -- Yes --> F[ListEnrolmentsRequest\nvalidate input] F --> G{enrol_type\nvalid enum value?} G -- No --> H([422 Validation Error\nenrol_type field]) G -- Yes --> I{per_page\n1–100?} I -- No --> J([422 Validation Error\nper_page field]) I -- Yes / absent --> K[toDTO → ListEnrolmentsDTO\nstudentId, enrolType, perPage] K --> L[EnrolmentService::list] L --> M[EnrolmentRepository::listForStudent] M --> N[(Query user_enrolments\nuserid = student\nstatus = Active/0\nenrol.status = 0\ncourse.visible = 1\n± enrol_type filter)] N --> O[Paginate results\nEager load enrol + course] O --> P[EnrolmentResource::collection\nMinimal shape per item] P --> Q([200 JSON\ndata array + meta pagination]) style C fill:#ef4444,color:#fff,stroke:none style E fill:#ef4444,color:#fff,stroke:none style H fill:#ef4444,color:#fff,stroke:none style J fill:#ef4444,color:#fff,stroke:none style Q fill:#10b981,color:#fff,stroke:none
GET /api/v1/enrollments/{id} — Detail Endpoint
flowchart TD A([HTTP GET /api/v1/enrollments/123]) --> B{Sanctum token\nvalid?} B -- No --> C([401 Unauthenticated]) B -- Yes --> D{Moodle user\nactive?} D -- No --> E([401 / Inactive]) D -- Yes --> F[ShowEnrolmentDTO\nenrolmentId=123, studentId=auth] F --> G[EnrolmentService::show] G --> H[EnrolmentRepository::findForStudent] H --> I[(Query user_enrolments\nid = 123\nuserid = student\nstatus = Active/0\nenrol.status = 0\ncourse.visible = 1)] I --> J{Record found?} J -- No / wrong student\n/ suspended / hidden --> K[ModelNotFoundException] K --> L[Wrapped in\nEnrolmentNotFoundException\nerrorCode = 2001] L --> M[Controller catches\nEnrolmentNotFoundException] M --> N([404 JSON\nerror.code = 2001\nerror.message = Enrollment not found.]) J -- Yes --> O[Eager load enrol.course] O --> P[new EnrolmentResource\n.withDetail\nFull course shape] P --> Q([200 JSON\ndata with full course fields\nstart_date, end_date, summary, format, category]) style C fill:#ef4444,color:#fff,stroke:none style E fill:#ef4444,color:#fff,stroke:none style N fill:#ef4444,color:#fff,stroke:none style Q fill:#10b981,color:#fff,stroke:none

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

architecture

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.

architecture

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.

architecture

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.

architecture

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.

architecture

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.

architecture

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().

architecture

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.

security

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.

security

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.

security

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.

coding-style

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)).

coding-style

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.

coding-style

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.

coding-style

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.

testing

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.

testing

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.

testing

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.

testing

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.