Laravel 13 File Upload: Storage, Validation, and Images
Uploading files in Laravel 13 seems trivial until the form grows: duplicate names, images at the wrong size, and paths nobody can find. The Storage facade, based on Flysystem, unifies local, public, and S3 disks with server-side validation and temporary URLs.
How Storage Works in Laravel 13
The Flysystem Abstraction and the Storage Facade
Laravel 13 keeps the filesystem layer that the 12 series consolidated: an abstraction built on the PHP Flysystem package in which each disk represents a different storage location. The Storage facade exposes a single API to read, write, move, and delete files, so your code does not know — and does not need to know — whether the file lives on the server's hard drive or in an S3 bucket.
Storage::disk('local')->put('reports/january.txt', $contents);
$text = Storage::disk('s3')->get('reports/january.txt');
Storage::disk('public')->delete('avatars/old.jpg');The local, public, and s3 Disks in config/filesystems.php
Configuration lives in config/filesystems.php. Out of the box you get three disks: local, which stores in storage/app/private and is invisible to the web; public, which stores in storage/app/public and is served by URL; and s3, which points to Amazon S3 or compatible services. The default disk is chosen through the FILESYSTEM_DISK environment variable, so the same code works on your machine and in production.
local vs. public: The storage:link Symlink
The public disk exists so the browser can actually load the file. To make that happen you create the symlink that exposes the folder: php artisan storage:link generates public/storage pointing to storage/app/public. Without this step, /storage/... routes return 404 even though the file exists on disk.
Read also
php artisan storage:linkUploading Files from a Form
The Form with enctype multipart/form-data and the UploadedFile Object
Everything starts in the form: it needs the enctype="multipart/form-data" attribute to send binary data and a file input. In the controller, $request->file('avatar') returns an UploadedFile instance with methods to store, validate, and inspect the original file.
<form method="POST" action="/profile/avatar" enctype="multipart/form-data">
@csrf
<input type="file" name="avatar" accept="image/*">
<button type="submit">Upload avatar</button>
</form>store() and storeAs(): Saving with Controlled Names
The shortest way to save is store(): it generates a random name, places the file in the given folder, and returns the resulting path. If you need to control the name — for example, the user's slug — use storeAs(). Saving to the public disk leaves the file ready to be served by URL.
$path = $request->file('avatar')->store('avatars', 'public');
// 'avatars/Ab3xY9kQ2m.png' (random name)
$path = $request->file('avatar')->storeAs('avatars', $user->slug . '.png', 'public');putFile() and Streaming Large Files
The Storage facade can also save uploaded files with Storage::putFile(), and for heavy files you should avoid loading everything into memory: writing from a stream makes Laravel read and write in chunks, which is essential for videos or ZIP files of several hundred MB.
Storage::disk('s3')->put('videos/' . $name, fopen($request->file('clip')->getRealPath(), 'r'));Server-Side File Validation
The file, image, mimes, and max Rules
Validation in Laravel is declared with rules on the field. For a typical avatar: image requires an image file, mimes restricts extensions, max limits the weight in kilobytes, and required prevents empty fields. The validator handles error messages and redirects with the errors for you.
$data = $request->validate([
'avatar' => ['required', 'image', 'mimes:jpeg,png,webp', 'max:2048'],
]);Validating Image Dimensions with dimensions
If the image must meet a minimum or maximum size, the dimensions rule checks pixels before touching the disk: dimensions:min_width=200,min_height=200,max_width=4000. This way you reject tiny photos or absurd panoramas without processing them.
Why You Should Never Trust Client-Side Validation
The browser can manipulate any form, so the real validation always lives on the server. This is not theory: CVE-2026-33687, published in June 2026, affected Sharp, a CMS built on Laravel with versions before 9.20.0. Its upload endpoint received the validation_rule parameter directly from the client and passed it to the validator, so an authenticated user could bypass file type restrictions. The lesson: rules are fixed in server-side code, never accepted from the request. For complex validations, a Form Request keeps rules and authorize() in one place.
Visibility, URLs, and Downloads
Public Files with url() and the public Disk
Once the symlink exists, Storage::url() returns the public path of the file: /storage/avatars/foo.png. For an absolute URL, combine it with the asset() helper. This is the standard way to serve avatars, covers, or downloadable documents without going through a controller.
$url = Storage::url($user->avatar); // /storage/avatars/foo.png
<img src="{{ asset(Storage::url($user->avatar)) }}" alt="Avatar">Private Files: Controlled Downloads and Temporary URLs on Local Disks
Files that must not be public live on the local disk (storage/app/private) and are served through a controller that checks permissions before returning the download. And since Laravel 12, signed temporary URLs — previously exclusive to S3 — also work on the local disk: temporaryUrl() generates a link that expires on its own, ideal for invoice attachments or PDFs in a private area.
return Storage::disk('local')->download($invoice->pdf);
$url = Storage::disk('local')->temporaryUrl($invoice->pdf, now()->addMinutes(5));Processing Images with Intervention Image
Resizing and Cropping on Upload
Intervention Image is the de facto standard for image manipulation in Laravel. Version 3 lets you open the uploaded file, resize or crop it with cover(), and save the result to any disk, all in a couple of lines. That gives you control over the final weight and stops users from uploading a 12 MB PNG.
use Intervention\Image\ImageManager;
$manager = new ImageManager(['driver' => 'gd']);
$manager->read($request->file('avatar')->getRealPath())
->cover(400, 400)
->save(storage_path('app/public/avatars/' . $name));Generating Thumbnails in a Queue to Keep Requests Fast
Resizing inside the HTTP request adds latency; for thumbnails or multiple versions (mini, medium, large) the right move is to queue the work. A job receives the stored path, generates the variants, and writes them to the public disk; the response returns to the user without waiting for the processing. It is the same pattern as the queues and jobs in Laravel 13 guide, applied to image processing.
class GenerateThumbnail implements ShouldQueue
{
public function handle(): void
{
$manager = new ImageManager(['driver' => 'gd']);
$manager->read(storage_path('app/public/' . $this->path))
->cover(150, 150)
->save(storage_path('app/public/thumbs/' . $this->path));
}
}
GenerateThumbnail::dispatch($path);Production: From Local to S3 and Compatible Services
Configuring the S3 Disk with Environment Variables
When the app grows — multiple servers, a CDN, backups — files move to object storage. Laravel ships the s3 disk already configured: you only need to install the league/flysystem-aws-s3-v3 package and fill in AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION, AWS_BUCKET, and AWS_URL in your environment. The bucket becomes just another disk.
AWS_ACCESS_KEY_ID=xxx
AWS_SECRET_ACCESS_KEY=xxx
AWS_DEFAULT_REGION=eu-west-1
AWS_BUCKET=blenderdeluxe-media
FILESYSTEM_DISK=s3Migrating Files Between Disks Without Rewriting Code
The beauty of the abstraction: your code uses Storage::disk('public') or simply the facade without a disk, and FILESYSTEM_DISK decides the default. Moving from local to S3 means changing environment variables, not rewriting the application. To move existing data, copy between disks with readStream and writeStream, or with a migration job.
Managed Object Storage: Forge and Alternatives
Beyond S3, any compatible service works: DigitalOcean Spaces, MinIO, or the managed object storage Laravel Forge added in July 2026 alongside its managed Valkey caches. They all speak the same S3 protocol, so Laravel's s3 disk consumes them without changes.
Best Practices and Common Mistakes
Sanitizing File Names and Restricting Extensions
Never use the user's original name for saving: it can contain paths, weird characters, or double extensions. Always generate the name (Str::random, a UUID, or the model's slug) and let storeAs() place it. Also remember that mimes validates the real content of the file, not just the extension, and always combine it with max for the weight.
Size Limits, Upload Timeouts, and Folder Permissions
Three classics that break uploads in production: upload_max_filesize and post_max_size in php.ini, client_max_body_size in Nginx, and write permissions in storage/. A typical failure is a 413 or a silent 500 when uploading a large file even though everything worked locally: check those three points before touching the code.
Conclusion
Uploading files in Laravel 13 stops being painful once you understand the disk model: a multipart/form-data form, real server-side validation, saving with store() or storeAs(), the storage:link symlink for public files, and temporary URLs for private ones. The example in this post — the avatar module of a blog like blenderdeluxe — covers that full flow: validation, a queued thumbnail, and files served through a public URL. If you want to keep hardening your forms, check out validation with Form Requests and queues and jobs for heavy processing.


