Moodle File System
🗂 Overview (5W)
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).
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.
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.
- 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
- Storage addressing:
{hash[0:2]}/{hash[2:4]}/{hash}path derived from SHA1 contenthash - Driver selection:
MoodleStorageDriverenum switches betweenmoodle_localandmoodle_s3disks viaMOODLE_STORAGE_DRIVERenv - 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
- 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
🗺 Flowchart
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
📋 Rules Applied
- Strict Request → Controller → Service → Repository → Model layering with no layer skipping —
FileControlleris 5–15 lines per action, all logic inFileService. - Dedicated read-only Eloquent models for Moodle tables in
app/Shared/Models/—MoodleFileandMoodleCourseModuleextendMoodleModelwith write guards. - Write access to Moodle table documented and scoped: INSERT-only via
DB::table()->insertGetId()— never through Eloquentsave(). - Module self-contained in
app/Modules/MoodleFile/; shared infrastructure (contracts, models) lives inapp/Shared/. - DTOs as
final readonlyclasses with constructor promotion used between all layers. - Interfaces bound in
AppServiceProvider; constructor injection throughout services and repository — noapp()helper in business logic. - API versioned under
/api/v1/files/; plural noun resource naming; kebab-case routes. - Consistent JSON response envelope with
data/errors/codefields.
- All endpoints behind
auth:sanctum— no public file access. - Authorization enforced in
FilePermissionCheckerby 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
FormRequestclasses; 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 viaFileResource. - Moodle API key stored in
.envand accessed viaconfig('moodle.api_key')— never hardcoded. - No mass assignment on Moodle-sourced data; explicit column selection in all queries.
- PHP 8.3 enums for fixed value sets:
MoodleStorageDriver,FileErrorCode. final readonlyclasses for all DTOs with constructor-promoted properties.matchexpression overswitchinFilePermissionChecker::checkRead.- All classes are
finalby 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
@throwsfor all raised exceptions. - Custom exception classes per domain (
FileNotFoundException,FileAccessDeniedException,FileStorageException) — no raw\Exceptionusage. - Single quotes for plain strings; no magic strings or numbers (enum values used instead).
- 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.RefreshDatabasetrait with in-test schema creation for Moodle tables (not owned by our migrations).- Feature tests hit a real (in-memory) database — no Eloquent mocking.