File storage and uploads
Filesystem disks, S3 configuration, validating uploads properly, streaming large files, and temporary URLs for private content.
Disks and the storage facade
// config/filesystems.php
'disks' => [
'local' => ['driver' => 'local', 'root' => storage_path('app/private'),
'serve' => true, 'throw' => true],
'public' => ['driver' => 'local', 'root' => storage_path('app/public'),
'url' => env('APP_URL').'/storage', 'visibility' => 'public'],
's3' => ['driver' => 's3', 'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'), 'bucket' => env('AWS_BUCKET'),
'visibility' => 'private', 'throw' => true],
],
Storage::disk('s3')->put('covers/'.$book->id.'.jpg', $contents, 'private');
Storage::disk('public')->url('covers/1.jpg');
Storage::disk('s3')->temporaryUrl('covers/1.jpg', now()->addMinutes(15));
Storage::disk('s3')->download('reports/2026.csv', 'report.csv');'throw' => trueturns silent storage failures into exceptions - set it on every disk you write to.- Never build a storage path from user input directly: a name like
../../.envescapes the root. Sanitise, or generate the path yourself. - The
publicdisk requiresphp artisan storage:link; anything sensitive belongs on a private disk served through a controller or a temporary URL.
⚠️
$request->file('x')->getClientOriginalName() is attacker-controlled. Store under a generated name (Str::uuid() plus an extension from the validated MIME type) and keep the original name in a database column if you need to display it.Validating uploads
$validated = $request->validate([
'cover' => ['required', 'file', 'image', 'mimes:jpeg,png,webp', 'max:5120',
'dimensions:min_width=400,min_height=300'],
'attachment' => ['nullable', 'file', 'mimes:pdf', 'max:20480'],
]);
$path = $request->file('cover')->store('covers', 's3');
// move the work off the request thread
ProcessCoverImage::dispatch($book, $path)->onQueue('images');| Rule | Checks | Gap it closes |
|---|---|---|
file | Is an uploaded file, not a string path | Local file disclosure |
image / mimes: | MIME sniffing of the content | A PHP script renamed to .jpg |
max: | Size in kilobytes | Disk exhaustion and memory spikes |
dimensions: | Pixel dimensions | Decompression bombs |
extensions: | Client extension | Weak alone - combine with mimes |
PHP's upload_max_filesize and post_max_size cap uploads before Laravel sees them. When a request silently arrives with no files, those two ini values are the first thing to check.
Streaming and images
// stream a large export instead of buffering it
return response()->streamDownload(function () use ($query) {
$out = fopen('php://output', 'w');
fputcsv($out, ['id', 'title', 'author']);
foreach ($query->lazyById(1000) as $row) {
fputcsv($out, [$row->id, $row->title, $row->author_name]);
}
fclose($out);
}, 'books.csv', ['Content-Type' => 'text/csv']);
// resize an upload to a thumbnail
$thumb = Image::read(Storage::disk('s3')->path($path))
->scale(width: 400)
->toWebp(quality: 80);- Use
lazyById()rather thanget()for exports - memory stays flat no matter how many rows there are. - Read a remote file through a temporary local copy, or the whole object lands in memory.
- Delete the previous object when replacing an upload, or the bucket grows forever; a queued cleanup job keeps the request fast.
FAQ
Where should uploads live?
Object storage for anything user-generated in production: it survives redeploys, scales, and can serve signed URLs. Local disks are for build artefacts and short-lived intermediate files.
How do I serve private files?
Through a controller that authorises the request and returns
Storage::download(), or with a short-lived temporary URL from S3. Never make the bucket public to work around an authorisation problem.Related
Building APIs with Sanctum Deployment and production hardening
Last refreshed 2026-09-18.