0% прочитано

Direct S3 Uploads: Why Large Files Do Not Need to Pass Through PHP

PHP does not need to proxy every byte of a large upload. The application can authorize the operation, choose the object path, and issue short-lived upload permission while the browser sends the file directly to S3-compatible storage.

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

In the previous article I covered temporary URLs for private files for reading a private object.

An upload can follow a very similar model, only the data travels in the opposite direction.

Laravel authorizes the operation, chooses the future object path, and generates short-lived upload permission.

The browser then sends the large file directly to S3-compatible storage.

Direct upload does not remove Laravel from the flow. Laravel controls permission and the destination object, while PHP does not proxy the large file body itself.

A Traditional Upload Sends the Entire File Through PHP

PHP sits in the path of every byte
Browser   |   | large file   vNginx   |   vPHP   |   | temporary upload   | request handling   | storage write   vS3-compatible storage

For a small file this can be perfectly reasonable.

With a large video, however, PHP becomes a proxy for a large data stream even though application code usually does not need to inspect every byte.

The request occupies a worker longer and passes through HTTP body limits, execution-time limits, and temporary server-side upload handling.

With Direct Uploads PHP Transfers Permission, Not the File

The application controls the operation without proxying the large file
1. Browser -> Laravel   file metadata 2. Laravel   authorize   choose object path   create short-lived upload permission 3. Browser -> S3-compatible storage   large file

Laravel Can Generate a Temporary Upload URL

Short-lived permission to write the object
[    'url' => $url,    'headers' => $headers,] = Storage::disk('s3')    ->temporaryUploadUrl(        $path,        now()->addMinutes(5),    );

temporaryUploadUrl() returns the upload URL and headers the client should include with the request.

This is not permanent write access to the bucket.

The URL targets a specific object path and has a limited lifetime.

The Application Chooses the Object Key, Not the Browser

Example server-generated object path
$path = sprintf(    'private/videos/%s.mp4',    (string) Str::uuid(),);

I do not let the client arbitrarily choose any S3 object key.

The application decides which storage area may be written to and generates a safe object name.

This prevents the client from overwriting another object or choosing a path outside the intended area.

I Authorize Before Issuing Upload Permission

Authorization happens first
$this->authorize(    'uploadVideo',    $resource,); $path = $pathFactory->forVideo(    $resource,); [    'url' => $url,    'headers' => $headers,] = Storage::disk('s3')    ->temporaryUploadUrl(        $path,        now()->addMinutes(5),

Being authenticated does not mean a user may upload a file in every context.

The application authorizes the specific operation before creating signed upload permission.

Once the URL is issued, storage does not ask Laravel about the user role. It validates the signature.

I Validate the Intended Upload Before Issuing Permission

Validate declared file metadata
$data = $request->validate([    'name' => [        'required',        'string',        'max:255',    ],     'size' => [        'required',        'integer',        'min:1',        'max:524288000',    ],     'type' => [        'required',

The server does not have the actual file yet, but the client can send its name, declared size, and content type.

Those values help reject clearly invalid upload attempts before issuing a signed URL.

Browser-supplied metadata is not final proof, however. The stored object should be verified after upload.

The Browser Then Uploads Directly to Storage

The large file no longer passes through PHP
const response = await fetch(    upload.url,    {        method: 'PUT',        headers: upload.headers,        body: file,    },) if (!response.ok) {    throw new Error(        'Upload failed',    )}

In this request the large video body travels directly from the browser to S3-compatible storage.

A PHP worker no longer waits while hundreds of megabytes pass through the application.

Laravel was needed to decide whether the upload was allowed and where it should go, not to relay the bytes themselves.

Direct Upload Does Not Mean the Object Must Be Public

I keep the private S3 storage model.

Temporary permission to write one object does not mean that object should receive a permanent public URL afterward.

The uploaded object can remain private, with separate short-lived access generated when it needs to be read.

Upload Permission Should Be Narrow, Not Bucket-Wide

One operation on one object
Allowed: PUTprivate/videos/7fd2...mp4  Not granted: list bucketdelete another fileread another filewrite arbitrary pathpermanent credentials

Direct Browser Upload Requires Correct CORS and a Reachable Storage URL

The server endpoint is not always a browser-facing address
Laravel container: http://minio:9000        |        vworks  Browser: http://minio:9000        |        XDocker service name may not resolve

With direct upload, the browser calls the storage HTTP endpoint directly, so CORS becomes part of the working flow.

I allow only the required origins, methods, and headers.

I also verify the generated host: Laravel inside Docker may reach minio:9000, while the user browser does not live inside that Docker network.

A Successful PUT Does Not Complete the Application Operation

The application finalizes the upload afterward
Browser   |   | PUT succeeds   vS3  then  Browser   |   | upload completed   vLaravel   |

Storage may accept the file successfully, but the application still needs to learn that the operation completed.

I do not persist a final ready-file state merely when the URL is issued because the user may never perform the upload.

After a successful PUT, the client notifies the application and the server verifies the object.

After Upload I Verify the Actual Stored Object

Verify the object after direct upload
$disk = Storage::disk('s3'); if (! $disk->exists($path)) {    throw ValidationException::withMessages([        'file' =>            'Uploaded object was not found.',    ]);} $size = $disk->size(    $path,); $mimeType = $disk->mimeType(    $path,);

Before issuing the URL, I only had metadata declared by the client.

After upload I can verify that the object exists, inspect its actual size, and read the stored MIME type.

Only after that verification do I move the application record into a ready-file state.

There Is a Pending State Between Permission and Completion

Issuing a URL does not mean the file is ready
requested    |    vupload permission issued    |    vpending upload    |    +---- abandoned    |    vobject uploaded    |    vserver verification    |

A user may receive permission, close the tab, lose the network, or abort the upload.

The intermediate application record should not remain indefinitely as though a ready file exists.

These states need an expiration or cleanup strategy.

If storage contains objects that were never finalized by the application, there should also be a safe way to remove them.

When Replacing a File I Remove the Old Object Only After Success

Keep the working file until the replacement is verified
Old file   |   | keep   v New direct upload   |   vverify new object   |   vupdate application state   |   vremove old object

If an existing file is being replaced, I do not delete the old object when the new upload URL is issued.

The new upload may never complete.

I verify the replacement first, update application state successfully, and only then clean up the previous object.

Direct Upload Does Not Remove Laravel Responsibility

PHP leaves the data path, not the policy boundary
Laravel still owns: - authentication- authorization- allowed file purpose- object path- declared limits- short permission lifetime- final verification- database state- replacement and cleanup rules  Storage owns: - receiving file bytes

Not Every File Needs a Direct Upload

I do not use direct upload merely because it is technically possible.

For small avatars or simple attachments, a normal PHP upload may remain much simpler.

The direct flow becomes especially useful for large files, long uploads, or situations where PHP should not remain between the browser and storage throughout the data transfer.

What I Verify for a Direct S3 Upload

My short checklist:

  • Is this upload operation authorized for the user?
  • Does the server generate the object path?
  • Are declared size and type checked before permission is issued?
  • Does temporaryUploadUrl() have a short lifetime?
  • Does the browser send all required signed headers?
  • Does CORS allow only the required flow?
  • Is the storage URL reachable by the browser, not only the Docker container?
  • Does the file remain private after upload?
  • Does the application verify the real stored object afterward?
  • Can abandoned uploads be cleaned up?
  • Is the old file kept until replacement succeeds?

PHP Should Control the Operation, Not Relay Every Byte

For large files I like separating control from data transfer.

Laravel authenticates the user, authorizes the operation, chooses the object path, validates declared metadata, and issues short-lived permission.

The browser then transfers the large file directly to S3-compatible storage.

After the upload completes, Laravel becomes involved again to verify the real object and persist the final state.

PHP remains the owner of application rules without becoming the transport for hundreds of megabytes of data.

Direct upload does not bypass the application. It only removes the application from the part of the path where it does not need to sit between the browser and storage.

Laravel decides whether an upload is allowed and where it belongs. The browser can send the large file directly to storage.