Laravel Tutorial · Lessons

Foundation

Basic

Medium

Advanced

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.

Storage::disk('…') put, get, url, delete… local storage/app/private public storage/app/public sftp / s3-compatible optional drivers public/storage symlink → web URL

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 a FormData and 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 random hashName(). Save the path in the database and build URLs with Storage::url().
  • Disks decouple code from location. The public disk needs php artisan storage:link, and sensitive files belong on a private disk behind an authorised route.
  • Test with Storage::fake() and UploadedFile::fake().