skip to content

In Laravel, how does Storage::temporaryUploadUrl() let a browser upload a patient's scan straight to S3, and what must the app still do after the upload?

level: seniorimportance: should knowfreq 30%

answer

  1. bytes skip your PHP workers
  2. returns url plus headers array
  3. s3: presigned PutObject
  4. local serve disk: signed PUT route
  5. verify existence, size, type afterwards

basics

~20 s

temporaryUploadUrl($path, $expiresAt) returns an array with a signed url and the headers the browser must send; on S3 it is a presigned PutObject. The app still chooses the key, authorizes, and afterwards verifies the object and records it.

solid answer

~40 s

On the `s3` driver, `Storage::temporaryUploadUrl($path, $expiration, $options)` builds a `PutObject` command for that key, merges `$options` into it, and returns `['url' => ..., 'headers' => ...]` from the presigned request; the browser must `PUT` the bytes with exactly those headers before the expiry. On a local disk with `'serve' => true` it returns a signed URL for Laravel's own `storage.{disk}.upload` route instead, which accepts a `PUT` and answers 204. Other drivers throw `RuntimeException` unless you register `buildTemporaryUploadUrlsUsing()`. The file never passes through Laravel's validator, so the app must pick the key server-side, authorize the user before signing, and after the client reports completion check `fileExists()`, `size()` and `mimeType()` before attaching the path to a record, and clean up keys that were signed but never confirmed.

code

php · 26 lines
php
<?php

namespace App\Http\Controllers;

use App\Models\Patient;
use App\Models\PendingUpload;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;

class ScanUploadController extends Controller
{
    public function __invoke(Request $request, Patient $patient): JsonResponse
    {
        abort_unless($request->user()->can('uploadScan', $patient), 403);

        $key = "scans/{$patient->id}/".Str::uuid().'.jpg';
        $pending = PendingUpload::create(['patient_id' => $patient->id, 'path' => $key]);

        ['url' => $url, 'headers' => $headers] = Storage::disk('s3')
            ->temporaryUploadUrl($key, now()->addMinutes(5), ['ContentType' => 'image/jpeg']);

        return response()->json(['id' => $pending->id, 'url' => $url, 'headers' => $headers]);
    }
}

go deeper

for a junior

Recall that temporaryUploadUrl() returns a url and headers for the browser to PUT the file directly to storage before it expires.

for a middle

Explain how the S3 driver presigns a PutObject, how a local serving disk uses a signed route, and which drivers throw RuntimeException.

for a senior

Build the whole flow: server-chosen keys, pending records, post-upload checks on existence, size and type, and cleanup of abandoned uploads.

for a principal

Decide when direct uploads are worth their extra moving parts versus proxied uploads, given validation, scanning and compliance needs.

## Why upload directly A patient portal lets people upload phone photos and scans of referral letters, sometimes tens of megabytes each. Routing those bytes through a PHP request means a worker busy for the whole upload, request-size limits on the web server, and a second transfer from your server to the bucket. A **temporary upload URL** lets the browser send the file straight to storage, while your app only signs the request and checks the result. ## What the method returns `temporaryUploadUrl($path, $expiration, array $options = [])` returns an array, not a string: ```php ['url' => $url, 'headers' => $headers] = Storage::disk('s3') ->temporaryUploadUrl($key, now()->addMinutes(5)); ``` | Disk | What `url` points at | `headers` | |---|---|---| | `s3` | a presigned `PutObject` request for the bucket and key | the headers of the signed request, which the client must send | | local with `'serve' => true` | Laravel's signed route `storage.{disk}.upload` | empty array | | other drivers | `RuntimeException: This driver does not support creating temporary upload URLs.` | — | On S3, `$options` are merged into the `PutObject` command (for example a `ContentType`), which becomes part of what the signature covers. A `temporary_url` key on the disk swaps the scheme, host and port of the returned URL. For drivers without native support, `Storage::disk('x')->buildTemporaryUploadUrlsUsing(fn ($path, $expiration, $options) => [...])` supplies your own builder. On a local serving disk, the route accepts a `PUT` with the file as the request body, checks the relative signature (404 in production, 403 elsewhere for a bad one), writes the body with `put()` and returns **204 No Content**. The body is read as a string, so this path suits development and modest files rather than very large uploads. ## The full flow 1. **Client asks to upload.** It sends intent only: file name, size and type, for display and early rejection. 2. **Server authorizes and chooses the key.** For example `scans/{patientId}/{uuid}.jpg`. Never let the client pick the key, or it can overwrite someone else's file. 3. **Server records a pending upload** (patient, key, expected type, expiry) and returns `url` and `headers`. 4. **Client `PUT`s the bytes** to `url` with every returned header, before the expiry. 5. **Client reports completion** to your app with the pending upload's id, not a path. 6. **Server verifies** before trusting anything: - `fileExists($key)`: the upload really happened; - `size($key)`: within your limit; - `mimeType($key)`: the stored content type. On S3 this comes from what the client sent, so treat it as a claim; if type matters (it does for medical files), read the first bytes with `readStream()` and check the file signature. 7. **Server attaches** the key to the patient's record and marks the pending row complete. 8. **Cleanup job** deletes keys whose pending rows expired without confirmation. ## What Laravel does not do for you - **No validation rules run.** The bytes never reach a form request, so rules like `max` or `mimes` are yours to reimplement in step 6. - **No single-use guarantee.** A signed upload URL can be used repeatedly until it expires; each use overwrites the same key. Keep expiries short. - **Browser access to the bucket.** Direct browser uploads need the bucket's cross-origin settings configured on the storage side; Laravel cannot set that for you. - **No record-keeping.** Nothing links the stored object to a model until your confirmation step does. ## Testing the flow locally The local disk's signed upload route makes the direct-upload flow testable without a bucket: 1. Point the upload code at a disk name taken from configuration. 2. In development, map that name to the skeleton's `local` disk, which has `'serve' => true`. 3. The browser code stays the same: it sends a `PUT` to `url` with `headers` (an empty array locally). 4. In production, map the name to the `s3` disk and the same code receives a presigned `PutObject` URL. Because the local route rejects bad signatures and expired links like the real thing, most of the flow, including expiry handling and the confirmation step, can be exercised before any cloud account exists. ## When not to use it - Small files on a single server: a normal upload request is simpler and gets Laravel's validation for free. - Content that must be scanned or transformed before anything is stored: receive it through the app or process it from a quarantine prefix. - Livewire components: Livewire has its own temporary upload handling.

  • What does temporaryUploadUrl() do on the skeleton's local disk?
    The local disk has `'serve' => true`, so Laravel registers a signed `storage.local.upload` route. `temporaryUploadUrl()` returns a temporary signed URL for it with an empty headers array; a `PUT` with a valid signature stores the request body with `put()` and returns 204. It is handy for developing the direct-upload flow without S3.
  • Why must the server, not the browser, choose the object key?
    The signature authorizes a write to exactly one key. If the client names it, a user can request a URL for another patient's path and overwrite that file. Choosing the key server-side, scoped to the authorized patient and made unique with a UUID, keeps each signed URL confined to a fresh location.

saying these in an interview costs you the question

  • temporaryUploadUrl() returns a single URL string
  • Laravel validation rules still run on files uploaded to a temporary upload URL
  • A temporary upload URL can only be used once
  • The client can safely choose the key it uploads to
  • temporaryUploadUrl() is supported on every disk driver