Student Profile Edit — Bio Only
Overview
What
Allows a student to edit only their bio via PATCH /api/v1/students/me. The profile picture is explicitly immutable — any attempt to change picture or imagealt is rejected with a 422. The bio is persisted to Moodle's user.description column through a dedicated, narrowly-scoped write model.
When
Triggered by an authenticated student sending a PATCH request. Rate-limited to 10 requests per minute (sensitive write endpoint). The StudentProfileUpdated event is dispatched synchronously; the Moodle forwarding listener runs on the queue (async) so it never blocks the HTTP response.
Why
PM requested "edit profile" functionality for students, explicitly scoped to the bio field. Writing to Moodle's core user table is forbidden by default per the architecture rules — this is a PM-approved exception using a dedicated write model with explicit column enumeration, ensuring no other column (especially picture) can be touched.
Where
Lives in the Student module (app/Modules/Student/). The write model is in Shared (app/Shared/Models/MoodleUserProfile.php) since it touches Moodle's core user table. The event, service, repository, DTO, request, and controller all reside within the Student module boundary.
How
The request flows through the standard architecture layers: UpdateProfileRequest validates input (requires bio, prohibits picture/imagealt) → UpdateProfileController (invokable, thin) delegates to StudentProfileService::updateProfile() → the service calls ProfileWriteRepository::updateBio() which delegates to MoodleUserProfile::updateBio() — an explicit query that touches exactly description, descriptionformat, and timemodified via an enumerated column update (no mass assignment). The service then dispatches StudentProfileUpdated (maps to Moodle's \core\event\user_updated), reloads the user model, and returns a StudentProfileDTO. The controller wraps it in the existing StudentResource.
Sequence Diagram
sequenceDiagram
participant B as Browser
participant R as Route / Middleware
participant RQ as UpdateProfileRequest
participant C as UpdateProfileController
participant S as StudentProfileService
participant RP as ProfileWriteRepository
participant M as MoodleUserProfile
participant DB as MySQL (user table)
participant E as Event Dispatcher
participant L as ForwardEventToMoodle
participant MO as Moodle Plugin (REST)
B->>R: PATCH /api/v1/students/me
R->>R: auth:sanctum + moodle.active + throttle:10,1
R->>RQ: Validate input
alt bio missing / too long
RQ-->>B: 422 Validation Error
end
alt picture or imagealt present
RQ-->>B: 422 Picture editing not allowed
end
RQ->>C: toDTO() produces UpdateProfileDTO
C->>S: updateProfile(dto, user)
S->>RP: updateBio(studentId, bio)
RP->>M: updateBio(userId, bio)
M->>DB: UPDATE user SET description, descriptionformat, timemodified WHERE id = ?
DB-->>M: OK
S->>E: dispatch(StudentProfileUpdated)
E->>L: handle(event) [queued]
L->>MO: POST /event (user_updated) [async]
S->>DB: user refresh
DB-->>S: Fresh user row
S-->>C: StudentProfileDTO
C-->>B: 200 JSON envelope with updated profile
Flowchart
flowchart TD
A[PATCH /api/v1/students/me] --> B{Authenticated?}
B -- No --> C[401 Unauthenticated]
B -- Yes --> D{Rate limit OK?}
D -- No --> E[429 Too Many Requests]
D -- Yes --> F{Validate input}
F -- bio missing --> G[422 bio is required]
F -- bio exceeds 65535 chars --> H[422 bio too long]
F -- picture or imagealt present --> I[422 picture not allowed]
F -- Valid --> J[UpdateProfileController]
J --> K[StudentProfileService.updateProfile]
K --> L[ProfileWriteRepository.updateBio]
L --> M[MoodleUserProfile.updateBio]
M --> N[UPDATE user SET description, descriptionformat, timemodified]
N --> O[Dispatch StudentProfileUpdated]
O --> P[ForwardEventToMoodle queued]
P --> Q[Refresh user model]
Q --> R[Return StudentProfileDTO]
R --> S[200 OK with StudentResource envelope]
style C fill:#ef4444,color:#fff
style E fill:#f59e0b,color:#000
style G fill:#ef4444,color:#fff
style H fill:#ef4444,color:#fff
style I fill:#ef4444,color:#fff
style S fill:#10b981,color:#000
Files Changed
| File Path | Layer | Description |
|---|---|---|
| app/Shared/Models/MoodleUserProfile.php | Model | Dedicated writable model for Moodle's user table. Exposes a single updateBio() method that touches exactly description, descriptionformat, timemodified. No mass assignment. |
| app/Modules/Student/Repositories/ProfileWriteRepositoryInterface.php | Repository | Contract for the narrow write path into the student's bio. Single method: updateBio(int, string). |
| app/Modules/Student/Repositories/ProfileWriteRepository.php | Repository | Concrete implementation — delegates to MoodleUserProfile::updateBio(). |
| app/Modules/Student/DTOs/UpdateProfileDTO.php | DTO | final readonly DTO carrying studentId and bio from the request to the service layer. |
| app/Modules/Student/Requests/UpdateProfileRequest.php | Request | FormRequest validating bio (required, string, max 65535) and prohibiting picture/imagealt. Includes toDTO() factory method. |
| app/Modules/Student/Controllers/UpdateProfileController.php | Controller | Invokable controller — calls service, returns StudentResource envelope. Thin: 6 lines of logic. |
| app/Modules/Student/Services/StudentProfileService.php | Service | Modified — added updateProfile() method and ProfileWriteRepositoryInterface dependency. Persists bio, dispatches event, reloads and returns profile. |
| app/Modules/Student/Events/StudentProfileUpdated.php | Event | Domain event extending BaseEvent. Maps to Moodle's \core\event\user_updated. CRUD=Update, EduLevel=Other, ContextLevel=User. |
| app/Modules/Student/routes.php | Route | Added PATCH me route with throttle:10,1 middleware, named api.v1.students.me.update. |
| app/Providers/AppServiceProvider.php | Config | Added binding: ProfileWriteRepositoryInterface → ProfileWriteRepository. |
| tests/Feature/Student/UpdateProfileTest.php | Test | 8 feature tests: success path, persistence verification, 401 unauthenticated, 422 (missing bio, max length, prohibited picture), picture immutability, event dispatch. |
| tests/Unit/Modules/Student/Services/StudentProfileServiceTest.php | Test | Modified — added ProfileWriteRepositoryInterface mock as constructor dependency to match updated service signature. |
| docs/openapi.yaml | Docs | Added PATCH /students/me endpoint spec with request/response schemas and error codes. |
| docs/postman_collection.json | Docs | Added Postman request with example response and envelope test script. |
Rules Applied
- Architecture — Moodle Write Exception: Writing to Moodle's core
usertable is forbidden by default. This feature uses the approved exception path: a dedicated model (MoodleUserProfile) with explicit write methods, no generic Eloquent mass-assignment, and columns enumerated in the query. - Architecture — Layer Discipline: Request flows through all layers without skipping: Route → Middleware → FormRequest → Controller → Service → Repository → Model → Event. Controller is thin (6 lines), business logic lives in the service, DB access in the repository.
- Architecture — Event System:
StudentProfileUpdatedextendsBaseEvent, carries Moodle-compatible metadata (crud, edulevel, component, target, action, objectTable), and is forwarded to Moodle via the existingForwardEventToMoodlelistener on the queue (async). - Architecture — DI Bindings:
ProfileWriteRepositoryInterfacebound to its concrete implementation inAppServiceProvider. Service depends on the interface, not the concrete class. - Security — Validation in FormRequest Only: All input validation lives in
UpdateProfileRequest. No inline validation in the controller or service. Theprohibitedrule blocks picture/imagealt at the validation layer. - Security — Own Data Only: The endpoint operates on
request->user()->idexclusively. No cross-student access is possible — there is no user ID parameter in the route. - Security — Rate Limiting:
throttle:10,1middleware applied (10 requests/minute), matching the "sensitive write" tier defined in the security rules. - Security — SQL Injection Prevention: The write uses Eloquent's query builder with parameter binding (
->where('id', $userId)->update([...])). No raw SQL or interpolation. - Coding Style — PHP 8.3 Features:
final readonlyDTO with constructor promotion, typed properties throughout,const intfor format and max-length constants, explicit return types on every method. - Coding Style — final by Default: All new classes are
final: controller, repository, DTO, request, event, test. - Coding Style — Constructor Injection: All dependencies injected via constructor. No
app()calls or service location in the service or repository layers. - Coding Style — PHPDoc: Every class has a summary docblock. Public methods have brief descriptions.
@param/@returnomitted when the signature is self-explanatory. - Testing — AAA Pattern: Every test follows Arrange → Act → Assert with descriptive
test_it_*naming. Tests cover: happy path, persistence, auth failure, validation failures (3 scenarios), picture immutability, and event dispatch. - Testing — Mocking Rules: External services (Moodle REST API) mocked via
Http::fake(). Events tested withEvent::fake(). Real database used viaRefreshDatabasetrait. No Moodle factory files — test data seeded viaDB::table()->insert(). - Testing — No Factories for Moodle Models: Tests seed data with
DB::table()->insertGetId()and manual model hydration, following the project's canonical pattern (nodatabase/factories/Moodle*Factory.php).