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' => true turns 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 ../../.env escapes the root. Sanitise, or generate the path yourself.
  • The public disk requires php 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');
RuleChecksGap it closes
fileIs an uploaded file, not a string pathLocal file disclosure
image / mimes:MIME sniffing of the contentA PHP script renamed to .jpg
max:Size in kilobytesDisk exhaustion and memory spikes
dimensions:Pixel dimensionsDecompression bombs
extensions:Client extensionWeak 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 than get() 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.

Building APIs with Sanctum Deployment and production hardening

Last refreshed 2026-09-18.