Після прямого завантаження у S3-сумісне сховище браузер уже може передати великий файл безпосередньо у сховище.
Але успішний PUT ще не означає, що файл повинен одразу стати постійною частиною системи.
Між "байти вже лежать у S3" і "цей файл активний у застосунку" мені потрібна окрема межа.
Для цього я використовую staging: спочатку файл потрапляє у тимчасовий шлях на кшталт tmp/..., потім проходить перевірку, після цього стан зберігається, і лише тоді файл активується.
Мій базовий потік: upload -> temporary storage -> validation -> persist -> activate
upload | vtemporary storagetmp/... | vvalidation | vpersist pending state | vpromote object | vЦей порядок дає застосунку час перевірити те, чого не може гарантувати сам факт успішного HTTP-завантаження.
Наприклад: чи об’єкт справді існує, чи відповідає очікуваному розміру, чи має допустимий тип і чи взагалі цей upload ще належить актуальній бізнес-операції.
Тимчасовий шлях відділяє отримані байти від активних файлів
$temporaryPath = sprintf( 'tmp/uploads/%s/%s', (string) $user->id, (string) Str::uuid(),);Я не дозволяю браузеру самому визначати кінцевий шлях об’єкта.
Сервер створює тимчасовий ключ і видає короткоживучий дозвіл саме для нього.
Префікс tmp/ одразу показує, що об’єкт ще не є канонічним файлом системи.
Upload URL дає право записати лише тимчасовий об’єкт
[ 'url' => $url, 'headers' => $headers,] = Storage::disk('s3') ->temporaryUploadUrl( $temporaryPath, $uploadExpiresAt, );Це добре поєднується з окремого строку дії для upload і read URL: дозвіл на запис короткий і веде не в постійний namespace, а в staging.
Навіть якщо upload завершився, застосунок ще має вирішити, чи цей об’єкт можна прийняти.
Успішний PUT не є активацією файла
Коли S3 повернув успіх, це підтверджує тільки завершення конкретного storage request.
Це не підтверджує, що користувач не скасував операцію, що форма ще актуальна, що бізнес-сутність існує або що metadata файла відповідає очікуванням.
Тому я не створюю логіку за принципом "upload succeeded, therefore file is active".
Після upload клієнт окремо просить завершити операцію
await fetch(upload.url, { method: 'PUT', headers: upload.headers, body: file,}); await fetch('/files/complete-upload', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ uploadId: upload.id, }),});Перший запит передає байти у сховище.
Другий повертає керування застосунку, де можна повторно авторизувати дію, знайти очікуваний temporary object і виконати перевірки.
Клієнт передає внутрішній upload identifier, а не довільний storage path.
Я перевіряю реальний об’єкт у сховищі, а не лише дані з браузера
$disk = Storage::disk('s3'); if (! $disk->exists($temporaryPath)) { throw ValidationException::withMessages([ 'file' => 'Uploaded object was not found.', ]);} $size = $disk->size($temporaryPath);$mimeType = $disk->mimeType($temporaryPath); if ($size > $maxBytes) { throw ValidationException::withMessages([ 'file' => 'Uploaded file is too large.', ]);}Перед видачею upload URL я можу перевірити заявлені клієнтом ім’я, тип і розмір.
Після upload я вже маю можливість перевіряти фактичний об’єкт у сховищі.
Саме цей другий етап визначає, чи temporary object може рухатися далі.
Перед активацією я зберігаю проміжний стан
$file = File::create([ 'status' => FileStatus::PENDING, 'temporary_path' => $temporaryPath, 'path' => null, 'size' => $size, 'mime_type' => $mimeType,]);Проміжний запис дає застосунку власний стан операції.
Він дозволяє відрізняти "об’єкт завантажено" від "об’єкт активовано" і зберігати потрібну metadata до фінального переходу.
Назви статусів можуть відрізнятися між проєктами. Важливий сам принцип: до завершення всіх кроків файл не повинен виглядати як повністю готовий.
Після перевірки об’єкт переходить у постійний namespace
$permanentPath = sprintf( 'private/files/%s', (string) $file->id,); Storage::disk('s3')->move( $temporaryPath, $permanentPath,);Активую лише після підтвердження постійного об’єкта
if (! Storage::disk('s3')->exists($permanentPath)) { throw new RuntimeException( 'Permanent object was not created.', );} $file->update([ 'status' => FileStatus::ACTIVE, 'path' => $permanentPath, 'temporary_path' => null,]);У моїй моделі ACTIVE має сильне значення.
Якщо запис активний, код, який читає цей запис, може очікувати, що канонічний object path уже існує.
Тому я не ставлю active до того, як storage transition завершився успішно.
Database transaction не робить S3 частиною тієї самої транзакції
MySQL transaction | +--> insert pending row +--> update status S3 operation | +--> copy object +--> delete temporary object These are not one atomic transaction.DB::transaction() може відкотити SQL-зміни, але не здатний автоматично повернути назад уже виконаний S3 request.
Так само успішна storage operation не гарантує, що наступний database update завершиться.
Саме тому staging корисний: він робить проміжний стан явним і дає місце для cleanup або повторної спроби.
Не кожен temporary upload дійде до активації
Користувач може закрити вкладку, втратити мережу, передумати або ніколи не надіслати completion request.
Тоді об’єкт залишиться у tmp/.
Це не виняткова ситуація, а нормальний наслідок direct upload flow. Тому temporary namespace має мати окрему cleanup-політику.
Старі tmp-об’єкти потрібно прибирати окремо
tmp object created | vnot activated | volder than allowed staging window | vsafe cleanupЯ не хочу, щоб tmp/ перетворився на безкінечне сховище покинутих файлів.
Cleanup може виконуватися за віком об’єкта, за persisted upload state або за обома ознаками.
Важливо не видаляти об’єкт, який саме зараз проходить завершення, тому cleanup теж повинен мати чітке правило щодо станів і часу.
Я не зберігаю tmp path як фінальне посилання на файл
Якщо бізнес-сутність одразу почне посилатися на tmp/..., staging фактично перестане бути staging.
Інший код почне сприймати temporary object як постійний, а cleanup стане небезпечним.
Постійний запис має отримати canonical path тільки після успішного promotion.
Оригінальне ім’я файла не повинно визначати storage key
Ім’я, яке прийшло з браузера, корисне як metadata для користувача, але не як authoritative object key.
Сервер сам створює технічний шлях, а оригінальне ім’я може зберігатися окремим полем після нормалізації.
Це спрощує staging, запобігає конфліктам і не дозволяє клієнту керувати структурою bucket-а.
При заміні старий активний файл не потрібно видаляти до завершення нового
old active file | | stays available |new upload | vtmp object | vvalidate | vpromote | vЯкщо користувач замінює вже активний файл, я не хочу видаляти старий на початку процесу.
Новий upload може не завершитися або не пройти validation.
Старий файл видаляється лише після того, як новий уже пройшов staging і став активним.
Completion має бути безпечним для повторного запиту
Клієнт може не отримати відповідь після успішного completion і повторити запит.
Тому завершення upload не повинно безконтрольно створювати другий запис або повторно активувати той самий object path.
Persisted upload state дає точку, за якою застосунок може визначити: операція ще pending, уже active або завершилася помилкою.
Staging також робить помилки видимими
Без проміжного стану важко відрізнити покинутий upload від validation failure або помилки promotion.
Коли етапи явні, я можу окремо бачити кількість pending uploads, validation failures, activation failures і stale temporary objects.
Це важливо не тільки для debugging, а й для безпечного cleanup.
Я тестую кожну межу окремо
upload request- temporary path is server-generated- upload permission targets tmp only completion- missing object is rejected- oversized object is rejected- invalid type is rejected- valid object creates pending state promotion- permanent object is created- temporary object is no longer canonical activation- record becomes active only after promotionМій список перевірки для temporary upload staging
Я перевіряю:
- Чи upload спочатку потрапляє у
tmp/, а не в permanent namespace? - Чи temporary path створює сервер?
- Чи успішний PUT не активує файл автоматично?
- Чи completion знову проходить через застосунок?
- Чи перевіряється фактичний object у storage?
- Чи є persisted pending state перед activation?
- Чи canonical path з’являється лише після promotion?
- Чи
ACTIVEозначає, що permanent object уже існує? - Чи stale tmp objects мають cleanup-політику?
- Чи при заміні старий файл залишається доступним до успішної активації нового?
- Чи completion безпечний для повторного запиту?
- Чи код не припускає, що MySQL і S3 є однією атомарною транзакцією?
Завантаження створює об’єкт, а активація створює довіру
Direct upload добре прибирає PHP з data path, але це не повинно прибирати застосунок із процесу прийняття рішення.
Браузер може передати байти прямо у S3. Проте саме Laravel визначає temporary path, перевіряє об’єкт, зберігає стан, переводить його у permanent namespace і лише тоді активує.
Для мене tmp/ є не просто технічною папкою. Це межа довіри між "ми отримали файл" і "система прийняла цей файл".
Upload створює об’єкт. Validation і activation роблять його частиною системи.
Тимчасовий файл може фізично існувати у сховищі, але до активації він ще не повинен мати статусу постійного ресурсу системи.



