Traditional file uploads route through your application server, consuming memory and bandwidth. S3 presigned URLs let clients upload directly to S3 while your server retains full access control.
The Three-Phase Flow
- ▸Phase 1 — Request: Client asks your API for a presigned PUT URL
- ▸Phase 2 — Upload: Client PUTs directly to S3 (server not involved)
- ▸Phase 3 — Confirm: Client notifies API; server verifies the object exists
Generating Presigned PUT URLs
php
public function getPresignedUrl(UploadRequest $request): JsonResponse
{
$this->authorize('upload', Document::class);
$allowed = ['jpg', 'jpeg', 'png', 'pdf'];
$ext = $request->input('extension');
abort_unless(in_array($ext, $allowed, true), 422, 'File type not permitted.');
$key = sprintf('uploads/pending/%d/%s.%s', auth()->id(), Str::uuid(), $ext);
$command = $this->s3->getCommand('PutObject', [
'Bucket' => config('filesystems.disks.s3.bucket'),
'Key' => $key,
'ContentType' => $request->input('content_type'),
'ACL' => 'private',
]);
$url = (string) $this->s3->createPresignedRequest($command, '+15 minutes')->getUri();
PendingUpload::create(['user_id' => auth()->id(), 'object_key' => $key, 'expires_at' => now()->addMinutes(15)]);
return response()->json(['upload_url' => $url, 'object_key' => $key]);
}Phase 3: Server-side Confirmation
Never trust the client claim that an upload succeeded. Always verify the object in S3 and check its size before moving it to a permanent location:
php
public function confirmUpload(ConfirmRequest $request): JsonResponse
{
$pending = PendingUpload::where('object_key', $request->object_key)
->where('user_id', auth()->id())
->where('expires_at', '>', now())
->firstOrFail();
try {
$meta = $this->s3->headObject([
'Bucket' => config('filesystems.disks.s3.bucket'),
'Key' => $pending->object_key,
]);
} catch (S3Exception) {
return response()->json(['error' => 'Upload not found in storage'], 422);
}
abort_if($meta['ContentLength'] > 10 * 1024 * 1024, 422, 'File exceeds size limit.');
// Move from pending/ to documents/
$permanentKey = str_replace('pending/', 'documents/', $pending->object_key);
// copyObject + deleteObject ...
$pending->delete();
return response()->json(['document_key' => $permanentKey]);
}Security Tips
- ▸Use short TTLs (10-15 minutes) on presigned URLs
- ▸Validate content-type server-side, not just client-claimed
- ▸Always verify object existence and size in Phase 3
- ▸Clean up expired pending uploads with a scheduled command
- ▸Restrict PutObject via S3 bucket policy to your key prefix only