Skip to main content
Written on 29/10/2026 from the audit of 25/10/2026, updated 01/11/2026 when the backend took over the layout. The maintained twin of this page is LMS_Frontend/R2_storage_paths.md. Keywords for search: R2, bucket, object key, files/, all-lessons, all-classrooms, all-quizzes, all-chatrooms, training_id, lesson_id, classroom_id, quiz_id, upload_file, import_to_r2, R2StoragePathService.
Goal. Files saved in Cloudflare R2 are organised into folders named after the slug of the training, exam / quiz, classroom or chatroom they belong to, all under files/.
Status on 01/11/2026: done, end to end.
  • ✅ Backend. The layout lives in one place, app/Services/R2StoragePathService.php, and every key is built there. POST /upload_file (type 3) reads the owner the app names (training_id + lesson_id for a saved lesson, classroom_id, or quiz_id), loads it, checks it belongs to the request’s company and writes the key itself. POST /lessons/import_to_r2 writes files/all-lessons/{training-slug}/bulk/ from training_id. Chat images, chat backups and instructor logos moved to their folders. A folder or folder_name string sent by a client is ignored. Guarded by tests/Feature/R2StoragePathsTest.php and tests/Feature/ChatroomTest.php.
  • ✅ Frontend (Update 184). R2UploadTarget sends ids, never a folder string; the import modal shows the folder the backend answers.
  • Existing objects keep their old keys (nothing is moved): every URL is stored in files.url, in the lesson / classroom HTML or in chatroom_attachments.path.

At a glance

The routes below are the admin ones. The Instructor and Content Manager roles open the same screens under their own routes.

1. The layout

A lesson that is not saved yet has no id, so its files carry no {lessonId}_ prefix: files/all-lessons/{training-slug}/{userId}_{timestamp}_{random8}.{ext}. One folder name is made by R2StoragePathService::segment(): lower case, anything but a letter, a digit, _ or - becomes a dash (so a classroom slug such as pmp-class(1) gives pmp-class-1, and .. can never climb), dashes trimmed, and training-{id} / classroom-{id} / quiz-{id} / chatroom-{id} when nothing is left. The app’s r2Segment() applies the same rule for what it displays.

2. Uploads that go to R2

2.1 Where each upload comes from

2.2 The key written now, and before

POST /r2_upload_file and POST /r2_bulk_upload (FilesRController) still write flat to files/, but the apps never call them. Rows 3 and 4 were requested with a .pdf extension; they are written with {ext} here because the classroom editor uploads images and PDFs, and question images are images.

2.3 What the Flutter app sends (Update 184)

The app never sends a folder. A folder field reaching the backend is ignored (not validated, never read).

2.4 Code behind each row


3. The contract: the owner ids the app sends

POST /upload_file (type 3) carries whose file it is, as multipart fields next to type=3, company_id and file. The backend loads the owner and builds the folder.
  • One owner at most. training_id, classroom_id and quiz_id together answer 422 on the second one (messages.r2_one_owner_only). No owner at all: the file lands flat in files/, as before.
  • The answer carries file_id, url and path (the key). files.file_name holds the full key of a type-3 upload.
  • A validation error of upload_file answers 422 ({ "message", "errors" }) since 01/11/2026; it used to answer 500.
Example request (lesson editor, saved lesson 318 of training 7, pmp-foundations)
✅ 200 OK
Example request (import, training 7)
The answer carries folder (files/all-lessons/pmp-foundations/bulk) next to chapter and lessons; the app shows it in its success dialog. See Chapters & Lessons Export / Import.
Since Update 174, step 3 of the quiz / exam editor (“Save & continue”) creates a new quiz as an unpublished draft (POST /quiz, no slug sent: the backend generates it) and the questions step is only reachable through that button. So quiz_id is known on create as on update, and the images land under the backend’s final slug. The draft is published (or not) by the final submit, which goes through the update endpoint.
A request that names no owner (a provider with no saved training / classroom / quiz yet, or another client) lands flat in files/, the behaviour of before. In practice the training draft is created on step 1 of the training editor, the classroom on step 1 of its stepper and the quiz on step 3, so the owner exists by the time the content editors upload.
Nothing has to be moved. Every URL is stored in files.url, inside the lesson / classroom HTML or in chatroom_attachments.path, so older files keep working from their old keys. Only new uploads follow the layout.

4. How the files are named

{random8} is 8 random characters made by Laravel’s Str::random(8) (R2StoragePathService::uploadFileName, used by FilesController@upload_file, type 3).
  • Allowed characters: letters and digits only (A–Z, a–z, 0–9), 62 in total, upper and lower case mixed.
  • No -, _, +, / or =: Laravel removes those from the random bytes it encodes, so the result is always safe in a URL or a file name.
  • Why it is there: the R2 bucket is public. With 62⁸ (about 218 trillion) possible values, nobody can guess a file’s URL from the user id and the timestamp alone.
Examples of full keys (user 42, lesson 318, timestamp 1793289600):
Two rows lowercase their random part, so they only use a–z and 0–9 (Str::lower(Str::random(n)) in ChatroomService):

5. What the backend does

app/Services/R2StoragePathService.php is the one place that knows the layout. Add a method there for a new kind of upload; never hard-code a bucket folder in a controller, and never trust a folder string from a client.
1

FilesController@upload_file, type 3 (rows 1 to 4)

Validates training_id, lesson_id, classroom_id, quiz_id (nullable integers), asks the service for the destination, names the file, putFileAs on the r2 disk (a false answer from the disk is a 500), stores the full key in files.file_name and answers file_id, url, path. See File Management & Storage §1.
2

Export_Import_Chapters_And_Lessons_Controller@import_lessons_to_r2 (row 5)

Loads the training through the service (another company’s → 422), writes every image to files/all-lessons/{training-slug}/bulk/ and answers folder. folder_name is no longer read.
3

ChatroomService::storeAttachment and ::backup (rows 6 and 7)

files/all-chatrooms/{slug}/ for the images; files/backups/conversations/{slug}/{adminId}_{Ymd-His}_{random8}.csv for the backups (BACKUP_FOLDER now points at the service’s constant). See Chatrooms §3.8 and §3.11.
4

UserBackgroundItemsController::storeLogoFile (row 8)

files/instructor_bg_items/{userId}_{timestamp}_{originalName}. The UserBackgroundItem.image payload is unchanged. See Instructor Dashboard §20.
The error texts are messages.r2_* (resources/lang/{en,fr,ar}/messages.php). Run the guard with DB_CONNECTION=sqlite DB_DATABASE=:memory: php artisan test --filter='R2StoragePaths|ChatroomTest'.

6. Where the apps show a bucket path

  • Import lessons modal (row 5): the “Storage folder” field shows the destination (files/all-lessons/{slug}/bulk/, locked: the training slug is not editable since Update 175), and the success dialog adds the folder the backend answered.
  • Chatroom backup (row 7): the “Backup saved” dialog shows the path the backend answers.
  • Nowhere else. The admin storage usage page (/admin-config-storage-usage) lists the folders the backend returns (List R2 Folders, endpoint #12 of the file management page: the first level under files/, so all-lessons, all-classrooms, all-quizzes, all-chatrooms, backups, instructor_bg_items and (root)).

7. Uploads that do NOT go to R2 (local server disk)

These are saved under storage/app/public/files/ on the backend server and served from /storage/files/...: The local-disk uploads (training covers, avatars, theme and landing images) would have to move to R2 before folders on the bucket apply to them at all. Serving them to the Flutter web app needs the /storage/ CORS header described on the file management page.

8. Other things the audit found

  • The HTML editor, quiz and training uploads hard-code company_id: 1 in the app, so every files row is tagged company 1, whatever the user’s real company is (frontend known issue 7). The owner check of upload_file compares the owner’s company to that company_id.
  • Local type-1 uploads are named {userId}{originalName} with no timestamp: if the same user uploads a second cover.png, it overwrites the first one.
  • One configuration finding about the backend repository is tracked privately in the workspace notes.