0% прочитано

A Temporary Upload Is Not a Permanent File: Staging Before Activation

After a direct upload, a file is not automatically ready to become part of the system. I first place it under a temporary path, validate it, persist the state, and only then activate it.

11 жовтня 2026 р. 11 хв читанняLaradock

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.

A temporary object means the bytes already exist, but the application does not yet consider the file complete and active.

My Base Flow: Upload -> Temporary Storage -> Validation -> Persist -> Activate

The file does not become permanent at upload time
upload  |  vtemporary storagetmp/...  |  vvalidation  |  vpersist pending state  |  vpromote object  |  v

This 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

The server chooses the temporary path
$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

The short-lived permission points to tmp
[    '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

Storage upload and business completion are separate steps
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

Validate the object after direct upload
$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

The record exists, but the file is not active yet
$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

Logical promotion from tmp to a permanent path
$permanentPath = sprintf(    'private/files/%s',    (string) $file->id,); Storage::disk('s3')->move(    $temporaryPath,    $permanentPath,);
In object storage, move should not be treated as a guaranteed atomic filesystem rename. The implementation may involve copy + delete, so failures between steps must be considered.

I Activate Only After the Permanent Object Exists

Active means the permanent object has been confirmed
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

Database and object storage have different failure boundaries
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

Cleanup targets only unactivated temporary objects
tmp object created        |        vnot activated        |        volder than allowed staging window        |        vsafe cleanup

I 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

Safe replacement keeps the old file until completion
old active file      |      | stays available      |new upload      |      vtmp object      |      vvalidate      |      vpromote      |      v

When 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

Checks for the staging flow
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

My 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 ACTIVE mean 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.