After a direct upload to S3-compatible storage, the browser can send a large file directly to storage.
But a successful PUT does not mean that the object should immediately become a permanent part of the application.
I want a clear boundary between "the bytes exist in S3" and "this file is active in the product."
I use staging for that boundary: the object first lands under a temporary path such as tmp/..., then it is validated, the application state is persisted, and only then is the file activated.
My Base Flow: Upload -> Temporary Storage -> Validation -> Persist -> Activate
upload | vtemporary storagetmp/... | vvalidation | vpersist pending state | vpromote object | vThis order gives the application time to verify what a successful HTTP upload cannot guarantee by itself.
For example: whether the object really exists, whether its size is acceptable, whether the type is allowed, and whether the upload still belongs to a valid business operation.
A Temporary Path Separates Received Bytes from Active Files
$temporaryPath = sprintf( 'tmp/uploads/%s/%s', (string) $user->id, (string) Str::uuid(),);I do not let the browser choose the final object path.
The server creates a temporary key and issues a short-lived permission for that key only.
The tmp/ prefix immediately communicates that the object is not yet the canonical application file.
The Upload URL Targets Only the Temporary Object
[ 'url' => $url, 'headers' => $headers,] = Storage::disk('s3') ->temporaryUploadUrl( $temporaryPath, $uploadExpiresAt, );This fits naturally with separate expiration policies for upload and read URLs: write permission is short-lived and targets staging rather than the permanent namespace.
Even after the upload completes, the application still decides whether the object can be accepted.
A Successful PUT Is Not File Activation
When S3 returns success, it confirms only the completion of that storage request.
It does not prove that the user did not cancel the operation, that the form is still current, that the business entity still exists, or that the object metadata matches expectations.
I therefore avoid the rule "upload succeeded, therefore the file is active."
After Upload the Client Explicitly Completes the Operation
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, }),});The first request transfers bytes to storage.
The second returns control to the application, where the action can be authorized again, the expected temporary object can be resolved, and validation can run.
The client sends an internal upload identifier rather than an arbitrary storage path.
I Validate the Actual Stored Object, Not Only Browser Metadata
$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.', ]);}Before issuing an upload URL, I can validate the name, type, and size declared by the client.
After upload, I can validate the actual object stored in S3.
This second validation determines whether the temporary object may move forward.
I Persist an Intermediate State Before Activation
$file = File::create([ 'status' => FileStatus::PENDING, 'temporary_path' => $temporaryPath, 'path' => null, 'size' => $size, 'mime_type' => $mimeType,]);An intermediate record gives the application its own operation state.
It distinguishes "object uploaded" from "object activated" and preserves the required metadata before the final transition.
Status names may differ between projects. The important principle is that a file should not appear fully ready before every required step has completed.
After Validation the Object Moves to the Permanent Namespace
$permanentPath = sprintf( 'private/files/%s', (string) $file->id,); Storage::disk('s3')->move( $temporaryPath, $permanentPath,);I Activate Only After the Permanent Object Exists
if (! Storage::disk('s3')->exists($permanentPath)) { throw new RuntimeException( 'Permanent object was not created.', );} $file->update([ 'status' => FileStatus::ACTIVE, 'path' => $permanentPath, 'temporary_path' => null,]);In this model, ACTIVE has a strong meaning.
If a record is active, code reading that record may expect the canonical object path to already exist.
I therefore do not mark it active before the storage transition has completed successfully.
A Database Transaction Does Not Include Object Storage
MySQL transaction | +--> insert pending row +--> update status S3 operation | +--> copy object +--> delete temporary object These are not one atomic transaction.DB::transaction() can roll back SQL changes, but it cannot automatically undo an S3 request that has already succeeded.
Likewise, a successful storage operation does not guarantee that the following database update will succeed.
This is one reason staging is useful: it makes the intermediate state explicit and gives cleanup or retry logic somewhere to operate.
Not Every Temporary Upload Reaches Activation
The user can close the tab, lose the connection, change their mind, or never send the completion request.
The object then remains under tmp/.
This is not an exceptional case but a normal consequence of a direct-upload flow. The temporary namespace therefore needs an explicit cleanup policy.
Stale Temporary Objects Need Separate Cleanup
tmp object created | vnot activated | volder than allowed staging window | vsafe cleanupI do not want tmp/ to become an endless collection of abandoned objects.
Cleanup can use object age, persisted upload state, or both.
It must avoid deleting an object that is currently being completed, so cleanup also needs explicit state and timing rules.
I Do Not Treat the Temporary Path as the Final File Reference
If a business entity immediately starts pointing at tmp/..., staging has effectively stopped being staging.
Other code will begin treating the temporary object as permanent, making cleanup dangerous.
The permanent record should receive its canonical path only after successful promotion.
The Original Filename Should Not Own the Storage Key
The filename supplied by the browser is useful as user-facing metadata, but it should not be the authoritative object key.
The server creates the technical path while the original name can be stored separately after normalization.
This makes staging predictable, avoids collisions, and prevents the client from controlling the bucket layout.
Keep the Old Active File Until the New One Is Ready
old active file | | stays available |new upload | vtmp object | vvalidate | vpromote | vWhen a user replaces an existing active file, I do not want to delete the old object at the beginning of the process.
The new upload may fail or fail validation.
The old object is removed only after the replacement has passed staging and become active.
Completion Should Tolerate Retries
The client may fail to receive a response after a successful completion request and retry it.
Completion should therefore avoid creating duplicate records or activating the same object path repeatedly.
Persisted upload state gives the application a place to determine whether the operation is still pending, already active, or failed.
Staging Also Makes Failures Observable
Without an intermediate state, it is difficult to distinguish an abandoned upload from a validation failure or a promotion failure.
When the stages are explicit, I can separately observe pending uploads, validation failures, activation failures, and stale temporary objects.
This helps not only debugging but also safe cleanup.
I Test Each Staging Boundary Separately
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 promotionMy Temporary Upload Staging Checklist
I verify:
- Does the upload first land under
tmp/rather than the permanent namespace? - Does the server create the temporary path?
- Does a successful PUT avoid activating the file automatically?
- Does completion return through the application?
- Is the actual object in storage validated?
- Is there a persisted pending state before activation?
- Does the canonical path appear only after promotion?
- Does
ACTIVEmean the permanent object already exists? - Do stale temporary objects have a cleanup policy?
- During replacement, does the old file remain available until the new one activates successfully?
- Can completion safely tolerate retries?
- Does the code avoid pretending that MySQL and S3 share one atomic transaction?
Upload Creates an Object, Activation Creates Trust
Direct upload removes PHP from the data path, but it should not remove the application from the decision path.
The browser can send bytes directly to S3. Laravel still owns the temporary path, validates the object, persists state, promotes it into the permanent namespace, and only then activates it.
For me, tmp/ is not merely a technical folder. It is a trust boundary between "we received an object" and "the system accepted this object."
Upload creates an object. Validation and activation make it part of the system.
A temporary file may physically exist in storage, but until activation it should not have the status of a permanent application resource.



