🔍 5W Analysis

📋 What
  • Single endpoint: POST /api/v1/auth/login
  • Accepts identifier (username or email) + password
  • Verifies bcrypt password against Moodle's mdldf_user table (read-only)
  • Enforces account status: deleted=0, suspended=0, confirmed=1
  • Issues a Sanctum Bearer token into mdldf_personal_access_tokens
  • Token TTL from mdldf_config.sessiontimeout (default 7200 s)
  • Dispatches StudentLoggedIn event → Moodle plugin (async)
  • Returns unified API envelope via LoginResource
When
  • Triggered on every student login attempt
  • Rate-limited to 5 requests/minute per IP (throttle middleware)
  • Token TTL read at login time from MoodleConfigReader (cached 24 h)
  • Event forwarding runs async on the queue — does not block the HTTP response
  • Request lifecycle: Route → Middleware(throttle:5,1) → LoginController → LoginRequest → AuthenticationService → MoodleUserRepository → DB → Token → Event → LoginResource
💡 Why
  • Students already have accounts in Moodle's mdldf_user table — no separate user store needed
  • The portal is stateless (mobile/SPA client) — Moodle session cookies are not usable
  • Sanctum tokens are stored in a prefixed table alongside Moodle's own data, keeping the schema unified under one DB connection
  • Event forwarding makes Moodle's audit trail accurate — login events appear as if they originated from Moodle itself
📁 Where
  • app/Modules/Auth/ — all Auth-specific classes
  • app/Shared/Models/MoodleModel, MoodleUser
  • app/Shared/Services/MoodleConfigReader.php
  • app/Shared/Http/Resources/ApiResource.php
  • config/auth.php, config/database.php
  • routes/api.php — loads Auth module routes
  • tests/Feature/Auth/ and tests/Unit/
How
  • DB Prefix: DB_TABLE_PREFIX=mdldf_ applied globally via config/database.php — all tables (Moodle, Sanctum, ours) share one prefix without manual handling
  • MoodleUser: Extends read-only MoodleModel; carries HasApiTokens so token writes go to the prefixed personal_access_tokens, not to mdldf_user
  • AuthenticationService: Lookup → password check → status flags → TTL from config → createToken() with expiry → dispatch event → return DTO
  • Error codes: Int-backed enum AuthErrorCode (1001 = invalid, 1002 = suspended) surfaced in the API envelope's code field
  • ApiResource envelope: Abstract JsonResource base — all responses share {success, message, data, errors, code} shape
👤 Who
  • Students — submit identifier + password to obtain a Bearer token
  • Moodle — owns mdldf_user; we only read from it. Receives forwarded login events via REST plugin
  • Our team — built all components in this plan (Phases 0–4)
  • Sanctum — manages token lifecycle in mdldf_personal_access_tokens

🔀 Sequence Diagram

Full HTTP request lifecycle — POST /api/v1/auth/login
sequenceDiagram participant B as Browser participant MW as Throttle Middleware participant C as LoginController participant R as LoginRequest participant S as AuthenticationService participant UR as MoodleUserRepository participant DB as mdldf_user participant CR as MoodleConfigReader participant Cache as Cache participant T as mdldf_personal_access_tokens participant EV as Event Dispatcher participant Q as Queue B->>+MW: POST /api/v1/auth/login MW-->>MW: Check rate limit (5/min per IP) MW->>+C: Pass through C->>+R: validate(identifier, password) alt Validation fails R-->>C: 422 — ValidationException C-->>B: 422 errors map end R-->>-C: LoginDTO C->>+S: login(dto) S->>+UR: findByIdentifier(identifier) UR->>+DB: SELECT WHERE username=? OR email=? DB-->>-UR: row | null UR-->>-S: MoodleUser | null alt User not found S-->>C: throw AuthenticationException C-->>B: 401 code:1001 end S->>S: Hash::check(password, user.password) alt Wrong password S-->>C: throw AuthenticationException C-->>B: 401 code:1001 end S->>S: Check deleted=0 AND confirmed=1 alt Deleted or unconfirmed S-->>C: throw AuthenticationException C-->>B: 401 code:1001 end S->>S: Check suspended=0 alt Suspended S-->>C: throw AccountSuspendedException C-->>B: 403 code:1002 end S->>+CR: get('sessiontimeout', 7200) CR->>+Cache: remember('moodle_config:sessiontimeout', 86400) Cache-->>-CR: cached value | null CR-->>-S: ttl (seconds) S->>+T: user->createToken('api', ['*'], expiresAt) T-->>-S: NewAccessToken S->>+EV: Event::dispatch(StudentLoggedIn) EV->>+Q: ForwardEventToMoodle (async) Q-->>-EV: queued EV-->>-S: dispatched S-->>-C: LoginResultDTO C-->>-B: LoginResource → 200 JSON envelope

🗺 Flowchart

Main success path, validation branches, and error paths with HTTP status codes
flowchart TD A([POST /api/v1/auth/login]) --> RL{Rate limit\n5 req/min?} RL -->|Exceeded| RL_ERR[429 Too Many Requests] RL -->|OK| VAL{LoginRequest\nvalidation passes?} VAL -->|No - missing/invalid fields| V422[422 Unprocessable Entity\nerrors map per field] VAL -->|Yes - LoginDTO built| FIND[MoodleUserRepository\nfindByIdentifier] FIND --> FOUND{User found\nin mdldf_user?} FOUND -->|No| AUTH_FAIL[throw AuthenticationException] FOUND -->|Yes| PWD{Hash::check\npassword matches?} PWD -->|No| AUTH_FAIL PWD -->|Yes| STATUS1{deleted=0\nAND confirmed=1?} STATUS1 -->|No| AUTH_FAIL AUTH_FAIL --> RESP401[401 Unauthorized\nsuccess:false\ncode:1001] STATUS1 -->|Yes| STATUS2{suspended=0?} STATUS2 -->|No| SUSP_FAIL[throw AccountSuspendedException] SUSP_FAIL --> RESP403[403 Forbidden\nsuccess:false\ncode:1002] STATUS2 -->|Yes| TTL[MoodleConfigReader\nget sessiontimeout\ncached 24h] TTL --> TOKEN[user->createToken\nwith expiresAt] TOKEN --> EVENT[Event::dispatch\nStudentLoggedIn] EVENT --> QUEUE[ForwardEventToMoodle\non Queue async] QUEUE --> RESP200[200 OK\nLoginResource envelope\nsuccess:true\ndata: token + student] style RESP401 fill:#3f1c1c,stroke:#ef4444,color:#fca5a5 style RESP403 fill:#3f2a0c,stroke:#f59e0b,color:#fcd34d style RESP200 fill:#0f2e1f,stroke:#10b981,color:#6ee7b7 style V422 fill:#1c1c3f,stroke:#818cf8,color:#c7d2fe style RL_ERR fill:#3f1c1c,stroke:#ef4444,color:#fca5a5

📂 Files Changed

File Path Layer Description
app/Modules/Auth/Controllers/LoginController.php Controller Single-action controller for POST /api/v1/auth/login. Delegates to AuthenticationService, maps AuthenticationException → 401 and AccountSuspendedException → 403. Max 15 lines.
app/Modules/Auth/Requests/LoginRequest.php FormRequest Validates identifier (string, max 100) and password (string, max 255). Provides toDTO() to convert validated input into a typed LoginDTO.
app/Modules/Auth/Services/AuthenticationService.php Service All business logic: user lookup → password check → status checks → token creation with TTL from Moodle config → event dispatch. Returns LoginResultDTO.
app/Modules/Auth/Repositories/MoodleUserRepository.php Repository Queries mdldf_user by username or email using DB::table('user') (prefix applied automatically). Hydrates a MoodleUser model from raw row data.
app/Modules/Auth/Repositories/MoodleUserRepositoryInterface.php Interface Contract for user lookup by identifier. Bound in AppServiceProvider to enable dependency injection and testability.
app/Modules/Auth/Resources/LoginResource.php Resource Extends ApiResource. Formats the success response: Bearer token, token_type, expires_at (ISO 8601), and student profile fields.
app/Modules/Auth/Events/StudentLoggedIn.php Event Extends BaseEvent. Maps to Moodle's \core\event\user_loggedin. Context level: User. Forwarded async to the Moodle plugin via ForwardEventToMoodle.
app/Modules/Auth/Enums/AuthErrorCode.php Enum Int-backed enum scoped to the Auth module: InvalidCredentials = 1001, AccountSuspended = 1002. Surfaced in the API envelope's code field.
app/Modules/Auth/DTOs/LoginDTO.php DTO final readonly DTO carrying identifier and password from the request layer to the service layer.
app/Modules/Auth/DTOs/LoginResultDTO.php DTO final readonly DTO carrying the issued NewAccessToken and the authenticated MoodleUser back to the controller.
app/Modules/Auth/Exceptions/AuthenticationException.php Exception Thrown for wrong credentials, deleted account, or unconfirmed account. Carries AuthErrorCode::InvalidCredentials (1001). Maps to HTTP 401.
app/Modules/Auth/Exceptions/AccountSuspendedException.php Exception Thrown when credentials are valid but account is suspended. Carries AuthErrorCode::AccountSuspended (1002). Maps to HTTP 403.
app/Modules/Auth/routes.php Route Defines POST v1/auth/login under throttle:5,1 middleware. Route name: api.v1.auth.login.
app/Shared/Models/MoodleModel.php Model Abstract Eloquent base for all Moodle tables. Disables timestamps. Overrides save() and delete() to throw LogicException when $readOnly = true.
app/Shared/Models/MoodleUser.php Model Extends MoodleModel. Uses HasApiTokens (Sanctum). Table: user (prefixed to mdldf_user). Hides password and secret. Read-only.
app/Shared/Services/MoodleConfigReader.php Service Reads key/value pairs from mdldf_config. Results cached for 24 h via Cache::remember. Used to resolve token TTL from sessiontimeout.
app/Shared/Http/Resources/ApiResource.php Resource Abstract JsonResource base. Provides unified envelope: {success, message, data, errors, code}. Static error() helper builds error responses.
app/Providers/AppServiceProvider.php Provider Binds MoodleConfigReaderInterface → MoodleConfigReader (singleton), MoodleUserRepositoryInterface → MoodleUserRepository, and AuthenticationService with constructor injection.
config/auth.php Config Added api guard using Sanctum driver with moodle_users provider. Added moodle_users provider pointing to MoodleUser::class.
config/database.php Config Added 'prefix' => env('DB_TABLE_PREFIX', '') to the MySQL connection. Enables uniform table prefixing for Moodle tables, Sanctum tokens, and our own tables.
routes/api.php Route Loads Auth module routes file via Route::middleware('api')->group(base_path('app/Modules/Auth/routes.php')).
tests/Feature/Auth/LoginTest.php Feature Test 14+ tests covering full HTTP cycle: success paths (username, email), 401/403/422 error cases, event dispatch assertion, token storage, envelope shape, and token expiry.
tests/Unit/Modules/Auth/Services/AuthenticationServiceTest.php Unit Test Unit tests for all failure paths in AuthenticationService: user not found, wrong password, deleted, unconfirmed, suspended. Uses Mockery for repository and config reader.
tests/Unit/Shared/Models/MoodleModelTest.php Unit Test Verifies read-only guard: save() throws on read-only models, delete() throws, and save() succeeds when $readOnly = false.
tests/Unit/Shared/Models/MoodleUserTest.php Unit Test Verifies MoodleUser: correct table name, timestamps disabled, password hidden, read-only guard, extends MoodleModel, uses HasApiTokens.
tests/Unit/Shared/Services/MoodleConfigReaderTest.php Unit Test Verifies config lookup by key, default fallback when key not found, and caching: DB is only queried once for the same key across multiple calls.

📏 Rules Applied

Architecture
Strict layer separation: LoginController is ≤15 lines, contains zero business logic — delegates entirely to AuthenticationService.
Module self-contained: all Auth classes (controller, service, repository, events, DTOs, exceptions, requests, resources, routes) live in app/Modules/Auth/.
Shared cross-cutting concerns in app/Shared/: MoodleModel, MoodleUser, ApiResource, MoodleConfigReader.
Constructor injection everywhere — AuthenticationService receives MoodleUserRepositoryInterface and MoodleConfigReaderInterface via constructor, not app().
Interfaces bound in AppServiceProvider::register() — services depend on contracts.
Read-only Moodle models: MoodleModel guards save() and delete() with LogicException. MoodleUser sets $readOnly = true.
Unified DB prefix via DB_TABLE_PREFIX env var in config/database.php — no manual table prefix handling anywhere in code.
API response envelope: all responses use {success, message, data, errors, code} shape defined in ApiResource.
Event forwarding runs async on queue — ForwardEventToMoodle listener does not block HTTP response.
API versioning: endpoint under /api/v1/auth/. Route name follows dot.notation: api.v1.auth.login.
DTOs are final readonly with typed constructor-promoted properties. No business logic in DTOs.
HTTP status codes: 200 success, 401 invalid credentials, 403 suspended, 422 validation failure, 429 rate limit.
Coding Style
PHP 8.3 features used: readonly properties (DTOs, exceptions), enum (AuthErrorCode), constructor promotion (DTOs, services), typed properties and return types everywhere.
final on all concrete classes: LoginController, AuthenticationService, MoodleUserRepository, LoginDTO, MoodleConfigReader, MoodleUser, etc.
Laravel-first: Hash:: facade for password check, Cache::remember() for config caching, Event::dispatch() for event dispatching, now()->addSeconds() (Carbon) for token expiry.
No magic strings: error codes in AuthErrorCode enum, config key 'sessiontimeout' is a named string constant in service code.
PHPDoc on every class and public method: class docblock (one sentence), @throws on all methods that raise exceptions, @param/@return only where type alone is insufficient.
Custom exception classes per domain: AuthenticationException and AccountSuspendedException — no raw \Exception thrown or caught.
Strict types: declare(strict_types=1) in every file. Strict comparison (===) used. Explicit (int) casts on DB column values.
Naming conventions followed: PascalCase classes, camelCase methods, snake_case DB columns, kebab-case route URIs, dot.notation route names.
Security
Rate limiting: throttle:5,1 on the login route — 5 attempts per minute per IP, as required for auth endpoints.
Password never logged or exposed: MoodleUser hides password and secret via $hidden. Service logic uses Hash::check() only.
Stateless API tokens: Sanctum Bearer tokens with TTL — no session cookies, no long-lived credentials.
Input validation in FormRequest: all input validated before reaching controller or service. No inline validation.
No SQL injection: all queries use Eloquent/Query Builder with parameter binding (DB::table('user')->where()).
No writes to Moodle tables: MoodleUser is read-only. Token writes go to mdldf_personal_access_tokens, not mdldf_user.
User enumeration prevention: same AuthenticationException thrown for "user not found" and "wrong password" — identical 401 response, same error code 1001.
Env config for secrets: Moodle API key and URL read via config('services.moodle.*') — never hardcoded or read directly from env() in business logic.
Testing
TDD workflow: tests written first (red), minimum implementation to pass (green), refactor. All test files exist for every implemented class.
AAA pattern: every test method follows Arrange → Act → Assert with clear section separation.
Test naming: snake_case with test_it_ prefix, describes behavior and expected outcome (e.g., test_it_returns_403_when_user_is_suspended).
Feature tests use RefreshDatabase trait and hit the real (in-memory SQLite) database — no mocking of Eloquent or DB layer.
Laravel fakes used correctly: Event::fake() for event dispatch assertion, Queue::fake() to prevent async listener execution, Http::fake() to intercept Moodle API calls.
Moodle tables created in test setUp via Schema::create() — no factories or migrations for Moodle-owned tables; raw DB::table()->insert() for test data.
Specific assertions: assertJsonPath() over generic assertOk(), exact status codes checked, JSON structure validated with assertJsonStructure().
100% coverage of event dispatching: test_it_dispatches_student_logged_in_event_on_success uses Event::assertDispatched(StudentLoggedIn::class).
Unit tests use Mockery to mock interfaces (MoodleUserRepositoryInterface, MoodleConfigReaderInterface) — not concrete classes.
Test independence: each test sets up its own data in setUp() and relies on RefreshDatabase for teardown — no shared mutable state.