File Uploads & Storage
Validating uploads, the Storage facade, disks and public URLs.
Avatars, attachments, CSV imports, product photos: sooner or later every app accepts files. Laravel splits the job in two. Validation decides whether a file is acceptable, and the Storage layer decides where it lives and how to get a URL for it.
Try it: a real upload endpoint
POST /api/demo/uploads/validate runs real Laravel validation on whatever file you pick and reports what Laravel learned about it. It never stores the file, so it is safe to leave public. Try a small image, then something that should fail: a .zip, an .exe, or a file over 2 MB.
The HTML side
A file input only sends the file if the form uses multipart/form-data. Without that attribute the browser posts only the file name, and you get a confusing "field is required" error.
<form method="POST" action="{{ route('attachments.store') }}" enctype="multipart/form-data">
@csrf
<input type="file" name="attachment" accept=".jpg,.png,.pdf">
@error('attachment') <p class="error">{{ $message }}</p> @enderror
<button>Upload</button>
</form>
From JavaScript, send a FormData object and let the browser set the Content-Type header with the multipart boundary. That's exactly what the demo above does:
const form = new FormData();
form.append('attachment', input.files[0]);
const response = await fetch('/api/demo/uploads/validate', {
method: 'POST',
headers: { Accept: 'application/json' }, // ask Laravel for JSON 422 errors
body: form, // no Content-Type header: the browser adds it
});
Validating uploads
Validation is the important half. A file name and the browser's reported type are both supplied by the client, so they can be faked. Laravel's File rule checks the actual file contents. This is the Form Request behind the endpoint:
app/Http/Requests/ValidateUploadRequest.php
public function rules(): array
{
return [
'attachment' => [
'required',
File::types(['jpg', 'jpeg', 'png', 'webp', 'pdf', 'txt'])->max(2 * 1024),
],
];
}
max() is in kilobytes, so 2 * 1024 means 2 MB. The common file rules:
| Rule | Checks |
|---|---|
File::types(['pdf', 'png']) / mimes:pdf,png |
The detected type (from the file's bytes) matches an allowed extension |
File::image() / image |
It's an image (jpg, png, gif, bmp, webp). SVG is excluded by default because it can carry scripts; opt in with File::image(allowSvg: true) |
->max(2048) / ->min(10) |
Size in KB |
File::image()->dimensions(Rule::dimensions()->maxWidth(2000)) |
Image width and height |
'photos.*' => [File::image()] |
Each file in a multiple upload |
Don't trust the original file name or client MIME type. The demo shows both client_mime (what the browser claimed) and detected_mime (what PHP found in the bytes). Rename a .txt file to .png and upload it: the client still says image/png, but validation fails because the content isn't an image.
PHP has its own limits too. upload_max_filesize and post_max_size in php.ini, plus nginx's client_max_body_size, can reject a large file before Laravel ever sees it. Keep them a little above your largest validation rule.
Inspecting the uploaded file
$request->file() returns an UploadedFile with useful methods. The endpoint reports the most common ones:
app/Http/Controllers/Api/UploadValidationController.php
public function __invoke(ValidateUploadRequest $request): JsonResponse
{
$file = $request->file('attachment');
return response()->json([
'valid' => true,
'file' => [
'original_name' => $file->getClientOriginalName(),
'client_mime' => $file->getClientMimeType(),
'detected_mime' => $file->getMimeType(),
'extension' => $file->extension(),
'size_kb' => round($file->getSize() / 1024, 1),
'would_be_stored_as' => 'attachments/'.$file->hashName(),
],
'stored' => false,
]);
}
hashName() produces a random 40-character name with the right extension. That's what store() uses, so two users uploading photo.jpg never overwrite each other and nobody can guess the URL.
Storing files
In a real app, storing a file is one line. Illustrative — the demo endpoint stops before this step:
// storage/app/private/attachments/<random>.pdf on the default disk
$path = $request->file('attachment')->store('attachments');
// storage/app/public/avatars/<random>.png, served from /storage/avatars/...
$path = $request->file('avatar')->store('avatars', 'public');
// Keep your own name (sanitise it first!)
$path = $request->file('report')->storeAs('reports', "report-{$project->id}.pdf", 'public');
$task->attachments()->create([
'path' => $path,
'original_name' => $request->file('attachment')->getClientOriginalName(),
]);
Store the path in the database, never the full URL. You can then change domains, or move the files to another disk, without rewriting rows.
Disks and the Storage facade
A disk is a named storage location defined in config/filesystems.php. Code talks to the disk name, so changing where files live is a config change, not a code change.
config/filesystems.php
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
'visibility' => 'public',
'throw' => false,
'report' => false,
],
The public disk lives outside the web root. php artisan storage:link creates the public/storage symlink that makes those files reachable by URL. That command is the most common "my uploaded image 404s" fix.
The everyday Storage calls:
Storage::disk('public')->url($path); // https://demo1.matt-9.com/storage/avatars/abc.png
Storage::disk('public')->exists($path);
Storage::disk('public')->delete($oldPath); // tidy up when replacing a file
Storage::get('attachments/abc.pdf'); // read contents (default disk)
Storage::put('exports/tasks.csv', $csv); // write a string
return Storage::download($path, 'Report.pdf'); // force a download with a friendly name
Private files
Anything sensitive (invoices, ID documents, exports) goes on a private disk and is served through a controller that checks permission first. Illustrative:
Route::get('/attachments/{attachment}', function (Attachment $attachment) {
Gate::authorize('view', $attachment);
return Storage::download($attachment->path, $attachment->original_name);
})->middleware('auth');
To hand out a link that expires, use Storage::temporaryUrl($path, now()->addMinutes(5)) (supported by S3-compatible drivers, and by the local disk when it has 'serve' => true, as this app's does) or a signed route.
How this site's admin uploads images
The Media section of this site's admin uses Filament's FileUpload field. Filament validates the file and stores it on the public disk, using the same Storage layer underneath:
app/Filament/Resources/MediaResource.php (excerpt)
FileUpload::make('url')
->label('Image')
->disk(env('FILESYSTEM_DISK') === 's3' ? 's3' : 'public')
->directory('cms-images/uploads')
->image()
->imagePreviewHeight('200')
->acceptedFileTypes(['image/jpeg', 'image/png', 'image/webp'])
->maxSize(5120)
The same rules appear in a different syntax: an image only, three allowed types, and a 5 MB limit (maxSize is also in KB).
Testing uploads
Storage::fake() swaps the disk for a temporary one, and UploadedFile::fake() makes files without touching your real filesystem. These are the real tests for the demo endpoint:
tests/Feature/Demo/DemoExtrasApiTest.php (excerpt)
public function test_upload_is_validated_but_never_stored(): void
{
Storage::fake();
$this->post(route('api.demo.uploads.validate'), [
'attachment' => UploadedFile::fake()->image('avatar.png', 40, 40),
], ['Accept' => 'application/json'])
->assertOk()
->assertJsonPath('file.extension', 'png')
->assertJsonPath('stored', false);
$this->assertEmpty(Storage::allFiles());
$this->post(route('api.demo.uploads.validate'), [
'attachment' => UploadedFile::fake()->create('virus.exe', 10),
], ['Accept' => 'application/json'])
->assertUnprocessable()
->assertJsonValidationErrors('attachment');
In a test for a storing endpoint you would assert Storage::disk('public')->assertExists($path) instead.
Key takeaways
- Forms need
enctype="multipart/form-data". From JavaScript, post aFormDataand let the browser set the headers. - Validate with
File::types()/File::image()and->max()(in KB). These check the file's real contents, not its name. store()gives each file a randomhashName(). Save the path in the database and build URLs withStorage::url().- Disks decouple code from location. The
publicdisk needsphp artisan storage:link, and sensitive files belong on a private disk behind an authorised route. - Test with
Storage::fake()andUploadedFile::fake().