🗂 Overview (5W)

📦 What

A shared file system infrastructure that reads and writes files from Moodle's content-addressed storage (mdldf_files), enforcing enrollment-based permission checks. Provides four REST endpoints: list files by context, show file metadata, stream file bytes, and upload files. Serves as the foundation for all file-consuming modules (Assignment, Forum, Course).

When

Triggered on every HTTP request to /api/v1/files/*. All routes are protected by auth:sanctum + moodle.active middleware, so the permission pipeline runs after authentication succeeds. Built after Auth, Enrollment, and Course modules; before Assignment and Forum modules which depend on this infrastructure.

💡 Why

The student portal needs to serve course resources and assignment attachments, and accept student file uploads. Moodle stores all files in a shared files table using content-addressed storage (SHA1 contenthash). Without this module, the portal cannot securely proxy or write Moodle-compatible file records, breaking assignment submissions and resource downloads.

📍 Where
  • Module: app/Modules/MoodleFile/
  • Shared models: app/Shared/Models/MoodleFile.php, MoodleCourseModule.php
  • Contracts: app/Shared/Contracts/
  • Config: config/moodle.php, config/filesystems.php
  • Routes: /api/v1/files/*
  • DB: mdldf_files, mdldf_context, mdldf_course_modules
⚙️ How
  • Storage addressing: {hash[0:2]}/{hash[2:4]}/{hash} path derived from SHA1 contenthash
  • Driver selection: MoodleStorageDriver enum switches between moodle_local and moodle_s3 disks via MOODLE_STORAGE_DRIVER env
  • Permission model: context-level dispatch — System/Category/Block = authenticated, User = own files, Course = enrollment check, Module = enrollment + visible flag
  • Upload dedup: SHA1 contenthash checked before disk write; DB insert via raw DB::table('files')->insertGetId() to bypass Eloquent lifecycle
  • 404 on deny: access-denied returns 404 to prevent leaking file existence
👤 Who
  • Students — download course resources and upload assignment submissions
  • Assignment module — delegates file upload and download to this module
  • Forum module — delegates attachment handling to this module
  • Course module — depends on this for resource file access

🔄 Sequence Diagram

Full HTTP Request Lifecycle — Stream File Content
sequenceDiagram autonumber participant Browser participant Middleware as Middleware (auth:sanctum) participant Ctrl as FileController participant Svc as FileService participant Checker as FilePermissionChecker participant Repo as FileRepository participant DB as MySQL (mdldf_*) participant Storage as MoodleFileStorage Browser->>Middleware: GET /api/v1/files/{id}/content Middleware->>Middleware: Verify Sanctum token Middleware->>Middleware: Check moodle.active (user not deleted/suspended) Middleware->>Ctrl: Authenticated request Ctrl->>Svc: stream(FileShowDTO{fileId, studentId}) Svc->>Repo: findById(fileId) Repo->>DB: SELECT * FROM files WHERE id=? AND filename != '.' DB-->>Repo: MoodleFile row Repo-->>Svc: MoodleFile model Svc->>Checker: checkRead(file, studentId) Checker->>DB: SELECT contextlevel, instanceid FROM context WHERE id=? DB-->>Checker: context row alt Context = Course (level 50) Checker->>DB: JOIN user_enrolments ON enrol WHERE courseid=? AND userid=? AND status=active DB-->>Checker: enrolled row (or empty) else Context = Module (level 70) Checker->>DB: SELECT course, visible FROM course_modules WHERE id=? DB-->>Checker: module row Checker->>DB: enrollment check (same as Course) DB-->>Checker: enrolled row (or empty) else Context = User (level 30) Checker->>Checker: file.userid === studentId ? else Context = System / Category / Block Checker->>Checker: pass (authenticated only) end alt Access denied Checker-->>Svc: throw FileAccessDeniedException Svc-->>Ctrl: exception propagates Ctrl-->>Browser: 404 JSON (hides file existence) end Checker-->>Svc: void (access granted) Svc->>Storage: readStream(contenthash) Storage->>Storage: pathFor(hash) → "ab/cd/abcdef..." Storage->>Storage: disk().readStream(path) alt File missing from disk Storage-->>Svc: throw FileStorageException Svc-->>Ctrl: exception propagates Ctrl-->>Browser: 500 JSON (storage error) end Storage-->>Svc: resource (stream handle) Svc-->>Ctrl: FileStreamDTO{stream, mimetype, filesize, filename} Ctrl-->>Browser: 200 StreamedResponse (Content-Type + Content-Disposition headers)
Upload File — POST /api/v1/files
sequenceDiagram autonumber participant Browser participant Middleware as Middleware participant Req as UploadFileRequest participant Ctrl as FileController participant Svc as FileService participant Repo as FileRepository participant Storage as MoodleFileStorage participant DB as MySQL (mdldf_files) participant Disk as Disk (local/S3) Browser->>Middleware: POST /api/v1/files (multipart) Middleware-->>Req: validated request Req->>Req: validate(file, context_id, component, filearea, item_id) alt Validation fails Req-->>Browser: 422 Unprocessable Entity end Req->>Ctrl: toDTO() → UploadFileDTO Ctrl->>Svc: upload(UploadFileDTO) Svc->>Svc: sha1_file(tmpPath) → contenthash Svc->>Svc: sha1("/{contextId}/...") → pathnamehash Svc->>Storage: exists(contenthash)? Storage->>Disk: disk().exists(ab/cd/contenthash) Disk-->>Storage: bool alt Content not yet on disk Svc->>Storage: put(contenthash, stream) Storage->>Disk: disk().writeStream(path, stream) end Svc->>Repo: insert(attributes) Repo->>DB: DB::table('files')->insertGetId({...}) DB-->>Repo: new file ID Repo-->>Svc: int id Svc->>Repo: findById(id) Repo->>DB: SELECT * FROM files WHERE id=? DB-->>Repo: MoodleFile row Repo-->>Svc: MoodleFile Svc-->>Ctrl: MoodleFile Ctrl-->>Browser: 201 Created + FileResource JSON

🗺 Flowchart

Main Success Path, Validation Branches & Error Paths
flowchart TD A([HTTP Request to /api/v1/files/*]) --> B{auth:sanctum
token valid?} B -- No --> B1[401 Unauthenticated] B -- Yes --> C{moodle.active
user active?} C -- No --> C1[403 Forbidden] C -- Yes --> D{Which endpoint?} D --> E1["GET /context/{contextId}
list files"] D --> E2["GET /{id}
show metadata"] D --> E3["GET /{id}/content
stream bytes"] D --> E4["POST /
upload file"] E1 --> F1[ListFilesRequest validates
component, filearea, per_page] F1 --> G1[FileService.list
FileRepository.listByContext] G1 --> H1[Paginated query
filename != '.'] H1 --> I1[200 FileResource collection] E2 --> F2[FileRepository.findById] F2 --> F2a{File exists?} F2a -- No --> ERR404[404 Not Found] F2a -- Yes --> F2b[FilePermissionChecker.checkRead] F2b --> PERM{Permission
check} E3 --> G3[FileService.stream → show first] G3 --> F2a PERM --> PERM1{Context level?} PERM1 -- System 10 / Category 40 / Block 80 --> PASS[✅ Pass] PERM1 -- User 30 --> PERM2{file.userid
== studentId?} PERM2 -- No --> ERR404 PERM2 -- Yes --> PASS PERM1 -- Course 50 --> PERM3{Active
enrollment?} PERM3 -- No --> ERR404 PERM3 -- Yes --> PASS PERM1 -- Module 70 --> PERM4{module.visible
== true?} PERM4 -- No --> ERR404 PERM4 -- Yes --> PERM3 PERM1 -- Unknown --> ERR404 PASS --> SHOW_END[200 FileResource JSON] PASS --> STREAM{Endpoint = stream?} STREAM -- Yes --> S1[MoodleFileStorage.readStream
ab/cd/hash path] S1 --> S2{File on
disk?} S2 -- No --> ERR500[500 Storage Error] S2 -- Yes --> S3[200 StreamedResponse
Content-Type / Content-Disposition] E4 --> V1[UploadFileRequest validates
file, context_id, component, filearea, item_id] V1 --> V1a{Valid?} V1a -- No --> ERR422[422 Validation Error] V1a -- Yes --> U1[sha1_file → contenthash
sha1 path → pathnamehash] U1 --> U2{contenthash
on disk?} U2 -- No --> U3[MoodleFileStorage.put
writeStream to disk] U2 -- Yes --> U4 U3 --> U4[FileRepository.insert
DB::table files insertGetId] U4 --> U5[findById → MoodleFile] U5 --> U6[201 Created + FileResource JSON] style B1 fill:#ef4444,color:#fff,stroke:none style C1 fill:#ef4444,color:#fff,stroke:none style ERR404 fill:#ef4444,color:#fff,stroke:none style ERR500 fill:#ef4444,color:#fff,stroke:none style ERR422 fill:#f59e0b,color:#000,stroke:none style I1 fill:#10b981,color:#fff,stroke:none style SHOW_END fill:#10b981,color:#fff,stroke:none style S3 fill:#10b981,color:#fff,stroke:none style U6 fill:#10b981,color:#fff,stroke:none

📁 Files Changed

File Path Layer Description
config/moodle.php Config New config file consolidating Moodle event API settings and file storage settings (MOODLE_STORAGE_DRIVER, MOODLE_DATA_PATH, S3 prefix).
config/filesystems.php Config Added moodle_local (local driver, filedir root) and moodle_s3 (S3 driver with Moodle prefix) named disks.
app/Shared/Models/MoodleFile.php Model Read-only Eloquent model for Moodle's files table. Casts filesize, timecreated, and timemodified. Write-guarded by MoodleModel base class.
app/Shared/Models/MoodleCourseModule.php Model Read-only Eloquent model for Moodle's course_modules table. Used by FilePermissionChecker to verify module visibility before granting file access.
app/Shared/Contracts/MoodleFileStorageInterface.php Contract Interface abstracting Moodle's content-addressed storage: readStream, exists, put. Enables testable storage injection.
app/Shared/Contracts/FilePermissionCheckerInterface.php Contract Interface for context-level file permission enforcement: single checkRead(MoodleFile, studentId) method that throws FileAccessDeniedException on denial.
app/Modules/MoodleFile/Enums/MoodleStorageDriver.php Enum Backed string enum (local | s3) with diskName() method returning the corresponding Laravel filesystem disk name.
app/Modules/MoodleFile/Enums/FileErrorCode.php Enum Integer error code enum: NotFound (5001), AccessDenied (5002), StorageError (5003), InvalidContext (5004). Used in API error responses.
app/Modules/MoodleFile/Exceptions/FileNotFoundException.php Exception Thrown by FileRepository::findById when the file row does not exist or is a directory sentinel (filename='.').
app/Modules/MoodleFile/Exceptions/FileAccessDeniedException.php Exception Thrown by FilePermissionChecker when the student lacks access. Deliberately caught with FileNotFoundException to return a unified 404 response.
app/Modules/MoodleFile/Exceptions/FileStorageException.php Exception Thrown by MoodleFileStorage on read/write failures. Named constructors: unreadable() and unwritable().
app/Modules/MoodleFile/DTOs/FileListQueryDTO.php DTO Carries query parameters for listing files: studentId, contextId, optional component/filearea/itemId filters, perPage. Final readonly class.
app/Modules/MoodleFile/DTOs/FileShowDTO.php DTO Carries file ID and student ID for metadata/stream operations. Used by FileService::show and stream.
app/Modules/MoodleFile/DTOs/FileStreamDTO.php DTO Carries a resource stream handle plus metadata (mimetype, filename, filesize, contenthash) back to the controller for the streamed HTTP response.
app/Modules/MoodleFile/DTOs/UploadFileDTO.php DTO All data needed to write a file: temp path, contextId, component, filearea, itemId, filepath, filename, userId, mimeType, filesize, optional source/author/license.
app/Modules/MoodleFile/Repositories/FileRepositoryInterface.php Contract Repository contract: listByContext, findById, insert. The insert method carries a @write-approved annotation linking to the approval document.
app/Modules/MoodleFile/Repositories/FileRepository.php Repository Queries mdldf_files with Eloquent (read) and raw DB::table('files')->insertGetId() (write). Excludes directory sentinels (filename != '.') in all queries.
app/Modules/MoodleFile/Services/MoodleFileStorage.php Service Implements MoodleFileStorageInterface. Computes two-level content-addressed path (ab/cd/abcdef...), delegates to the selected Laravel disk via MoodleStorageDriver.
app/Modules/MoodleFile/Services/FilePermissionChecker.php Service Implements FilePermissionCheckerInterface. Resolves context level from mdldf_context, dispatches to ownership or enrollment checks via match expression.
app/Modules/MoodleFile/Services/FileService.php Service Orchestrates all file operations: list, show, stream, upload. SHA1 contenthash dedup logic in upload. Injects repository, permission checker, and storage via constructor.
app/Modules/MoodleFile/Requests/ListFilesRequest.php Request FormRequest for GET list. Validates optional component, filearea, item_id, per_page. Exposes toDTO(int $contextId).
app/Modules/MoodleFile/Requests/UploadFileRequest.php Request FormRequest for POST upload. Validates required file (max 50MB), context_id, component, filearea, item_id, optional filepath/author/license. Exposes toDTO().
app/Modules/MoodleFile/Resources/FileResource.php Resource JSON API resource for MoodleFile. Exposes id, filename, filepath, component, filearea, itemid, mimetype, filesize, author, license, time_created, time_modified. Hides internal hashes and userid.
app/Modules/MoodleFile/Controllers/FileController.php Controller Thin controller: delegates to FileService, catches domain exceptions and maps them to HTTP responses. Handles index, show, content (stream), and upload actions.
app/Modules/MoodleFile/routes.php Routes Defines four routes under v1/files prefix with auth:sanctum + moodle.active middleware group.
tests/Feature/MoodleFile/FileTest.php Test Feature test class (15 tests) covering: unauthenticated 401s, paginated list, directory sentinel exclusion, component filtering, pagination, file metadata, 404 on non-existent, 404 on non-enrolled, streaming with correct headers, upload success/422 validation, and envelope structure.
tests/Unit/Modules/MoodleFile/Services/MoodleFileStorageTest.php Test Unit tests for MoodleFileStorage: path derivation, readStream success/failure, exists, put success/failure using Storage::fake.
tests/Unit/Modules/MoodleFile/Services/FilePermissionCheckerTest.php Test Unit tests covering all context levels: System/Category/Block pass for authenticated, User context ownership check, Course enrollment check, Module visibility + enrollment check.
tests/Unit/Modules/MoodleFile/Enums/MoodleStorageDriverTest.php Test Unit tests asserting diskName() returns correct disk names for Local and S3 driver values.
tests/Unit/Modules/MoodleFile/Enums/FileErrorCodeTest.php Test Unit tests asserting correct integer values for all four error codes.
tests/Unit/Modules/MoodleFile/DTOs/FileListQueryDTOTest.php Test Unit tests for DTO construction, default values, and readonly enforcement.
tests/Unit/Modules/MoodleFile/DTOs/FileShowDTOTest.php Test Unit tests for FileShowDTO property assignment.
tests/Unit/Modules/MoodleFile/DTOs/FileStreamDTOTest.php Test Unit tests for FileStreamDTO property assignment including resource stream.
tests/Unit/Modules/MoodleFile/DTOs/UploadFileDTOTest.php Test Unit tests for UploadFileDTO including optional nullable fields.
tests/Unit/Shared/Models/MoodleFileTest.php Test Unit tests asserting correct table name, read-only guard (save/delete throw), and attribute casts.
tests/Unit/Shared/Models/MoodleCourseModuleTest.php Test Unit tests for MoodleCourseModule table name, read-only guard, and boolean visible cast.
plans/moodle-file-system/write-access-approval.md Docs Write access approval document for the mdldf_files Moodle table. Scopes INSERT-only access, documents justification and risk mitigations.

📋 Rules Applied

Architecture
  • Strict Request → Controller → Service → Repository → Model layering with no layer skipping — FileController is 5–15 lines per action, all logic in FileService.
  • Dedicated read-only Eloquent models for Moodle tables in app/Shared/Models/MoodleFile and MoodleCourseModule extend MoodleModel with write guards.
  • Write access to Moodle table documented and scoped: INSERT-only via DB::table()->insertGetId() — never through Eloquent save().
  • Module self-contained in app/Modules/MoodleFile/; shared infrastructure (contracts, models) lives in app/Shared/.
  • DTOs as final readonly classes with constructor promotion used between all layers.
  • Interfaces bound in AppServiceProvider; constructor injection throughout services and repository — no app() helper in business logic.
  • API versioned under /api/v1/files/; plural noun resource naming; kebab-case routes.
  • Consistent JSON response envelope with data / errors / code fields.
Security
  • All endpoints behind auth:sanctum — no public file access.
  • Authorization enforced in FilePermissionChecker by context level — students can only access their own files or files from enrolled courses.
  • Access-denied returns 404, not 403 — prevents leaking file existence information.
  • All input validated in dedicated FormRequest classes; no inline validation in controller or service.
  • File upload limited to 50 MB; MIME type inferred server-side, not from client header.
  • Internal identifiers (contenthash, pathnamehash, userid) excluded from API responses via FileResource.
  • Moodle API key stored in .env and accessed via config('moodle.api_key') — never hardcoded.
  • No mass assignment on Moodle-sourced data; explicit column selection in all queries.
Coding Style
  • PHP 8.3 enums for fixed value sets: MoodleStorageDriver, FileErrorCode.
  • final readonly classes for all DTOs with constructor-promoted properties.
  • match expression over switch in FilePermissionChecker::checkRead.
  • All classes are final by default; no untyped properties; strict type declarations on every file.
  • Laravel-first: Storage::disk() instead of filesystem calls; Collection-based Eloquent queries; no raw PHP string functions where Laravel helpers exist.
  • Named arguments used for clarity on 3+ parameter calls.
  • Every class and public method has a PHPDoc with brief description and @throws for all raised exceptions.
  • Custom exception classes per domain (FileNotFoundException, FileAccessDeniedException, FileStorageException) — no raw \Exception usage.
  • Single quotes for plain strings; no magic strings or numbers (enum values used instead).
Testing
  • TDD workflow followed: tests written before implementation for each component.
  • Feature tests cover: happy paths, auth failures (401), permission denials (404), validation failures (422), storage errors (500), and response envelope structure.
  • Unit tests for every service, enum, and DTO class, mirroring the app structure.
  • Test methods named test_it_* in snake_case, describing the expected behavior.
  • AAA (Arrange → Act → Assert) pattern in all tests; one assertion concept per test.
  • Storage::fake('moodle_local') used in feature tests — no real disk access.
  • Http::fake() used to stub Moodle event API calls in feature tests.
  • RefreshDatabase trait with in-test schema creation for Moodle tables (not owned by our migrations).
  • Feature tests hit a real (in-memory) database — no Eloquent mocking.