Handling file uploads in Laravel 13

  • avatar
  • 90 Views
  • 9 mins read

Handling file uploads is one of those things that sounds simple at first but has plenty of moving parts: storage drivers, validation, security, public access, multiple files. Laravel 13 ships with a solid, well-rounded set of tools to deal with all of this without pulling in third-party packages. This article walks through the full lifecycle of a file upload in a Laravel 13 application, from the HTML form to retrieving files from storage, with practical examples along the way.

How Laravel handles uploaded files

When a user submits a form with a file field, PHP receives the file and temporarily places it on the server. Laravel wraps that temporary file in an Illuminate\\Http\\UploadedFile instance, which you can retrieve from the request using $request->file('field_name'). This object gives you a clean API to inspect the file and move it to permanent storage.

Laravel's storage layer is configured in config/filesystems.php. You interact with it through the Storage facade, which abstracts away the underlying driver entirely. This means the same code works locally and against S3 without any changes.

The three most common storage drivers are:

  • local - stores files on the server's own filesystem, under storage/app/private by default

  • public - also local, but under storage/app/public, intended for user-accessible files

  • s3 - stores files in Amazon S3 or any S3-compatible service (DigitalOcean Spaces, Cloudflare R2, Hetzner Object Storage, etc.)

Setting up the form and routes

A file upload form requires enctype="multipart/form-data". Without it, the browser won't transmit the file contents.

<form action="/upload" method="POST" enctype="multipart/form-data">
@csrf
<input type="file" name="avatar">
<button type="submit">Upload</button>
</form>

In routes/web.php, define the two routes:

use App\\Http\\Controllers\\UploadController;

Route::get('/upload', [UploadController::class, 'create']);
Route::post('/upload', [UploadController::class, 'store']);

Validating uploaded files

Before touching any file, validate it. Laravel's request validator covers the most common file rules out of the box. The key ones are:

  • file - ensures the field is an uploaded file

  • image - ensures the file is a recognized image format (jpeg, png, gif, webp, svg, bmp)

  • mimes:jpg,png,pdf - checks the file extension against a list; Laravel maps the extension to an expected MIME type and reads the actual file contents to verify it

  • mimetypes:image/jpeg,application/pdf - checks the MIME type detected directly from the file contents by PHP's Fileinfo extension

  • max:2048 - maximum size in kilobytes

  • dimensions:min_width=100,min_height=100 - enforces image dimensions

Using mimes and mimetypes together is more secure than either alone, since it makes it significantly harder to spoof a file by either renaming it or tampering with its contents.

Here's a FormRequest class that enforces these rules:

<?php

namespace App\\Http\\Requests;

use Illuminate\\Foundation\\Http\\FormRequest;

class StoreAvatarRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}

public function rules(): array
{
return [
'avatar' => [
'required',
'file',
'image',
'mimes:jpeg,png,webp',
'mimetypes:image/jpeg,image/png,image/webp',
'max:2048', // 2 MB
'dimensions:min_width=100,min_height=100,max_width=4000,max_height=4000',
],
];
}
}

For non-image documents, swap out image and dimensions for the appropriate MIME types:

'document' => [
'required',
'file',
'mimes:pdf,docx,xlsx',
'max:10240', // 10 MB
],

Storing uploaded files

Once validation passes, you have a couple of options for storing the file. The simplest is calling store() directly on the UploadedFile instance:

$path = $request->file('avatar')->store('avatars');

This saves the file to the default disk (local by default) inside an avatars/ directory and generates a unique random filename automatically. The return value is the path relative to the disk root.

To control the disk, pass it as the second argument:

$path = $request->file('avatar')->store('avatars', 'public');

To set a specific filename, use storeAs():

$path = $request->file('avatar')->storeAs('avatars', 'user-42.jpg', 'public');

The Storage facade gives you the same options with slightly different syntax:

use Illuminate\\Support\\Facades\\Storage;

$path = Storage::disk('public')->putFile('avatars', $request->file('avatar'));
$path = Storage::disk('public')->putFileAs('avatars', $request->file('avatar'), 'user-42.jpg');

putFile and putFileAs automatically stream the file to storage rather than loading it fully into memory first, which is important for large files. For very large uploads, streaming is the right default.

A full controller example

<?php

namespace App\\Http\\Controllers;

use App\\Http\\Requests\\StoreAvatarRequest;
use Illuminate\\Support\\Facades\\Storage;

class UploadController extends Controller
{
public function create()
{
return view('upload.create');
}

public function store(StoreAvatarRequest $request)
{
$path = $request->file('avatar')->store('avatars', 'public');

// Persist the path to the database
auth()->user()->update(['avatar' => $path]);

return redirect()->back()->with('success', 'Avatar uploaded.');
}
}

Making files publicly accessible

The public disk stores files under storage/app/public, but this directory isn't directly accessible from the web. You need to create a symbolic link that points public/storage to storage/app/public:

php artisan storage:link

Once the symlink exists, generate a URL to any public file using the Storage facade:

$url = Storage::disk('public')->url($path);
// e.g. https://example.com/storage/avatars/abcd1234.jpg

You can also use the asset() helper directly:

echo asset('storage/' . $path);

If you need to define additional symlinks, add them to the links array in config/filesystems.php and run storage:link again:

'links' => [
public_path('storage') => storage_path('app/public'),
public_path('uploads') => storage_path('app/uploads'),
],

Handling multiple file uploads

Multiple file inputs work almost the same way. Add multiple to the input and use a wildcard validation rule:

<input type="file" name="photos[]" multiple>

Update validation rules to verify each uploaded file:

public function rules(): array
{
return [
'photos' => ['required', 'array', 'max:10'],
'photos.*' => ['file', 'image', 'mimes:jpeg,png,webp', 'max:4096'],
];
}

In the controller, loop over the files and store each one:

$paths = [];

foreach ($request->file('photos') as $photo) {
$paths[] = $photo->store('photos', 'public');
}

Private file access with temporary URLs

Not every uploaded file should be publicly accessible at a static URL. For private files, store them on the local disk (or a private S3 bucket) and serve them through a controller that verifies authorization. Laravel supports temporary signed URLs for this:

// In AppServiceProvider::boot()
use Illuminate\\Support\\Facades\\Storage;
use Illuminate\\Support\\Facades\\URL;
use DateTime;

Storage::disk('local')->buildTemporaryUrlsUsing(
function (string $path, DateTime $expiration, array $options) {
return URL::temporarySignedRoute(
'files.download',
$expiration,
array_merge($options, ['path' => $path])
);
}
);

Then generate a short-lived URL whenever a user needs access:

$url = Storage::temporaryUrl('private/report.pdf', now()->addMinutes(15));

For S3 disks, temporaryUrl works natively without any extra setup:

$url = Storage::disk('s3')->temporaryUrl('reports/report.pdf', now()->addMinutes(30));

The S3 temporaryUploadUrl method also lets clients upload directly to S3 from the browser without the file passing through your server at all:

['url' => $uploadUrl, 'headers' => $headers] = Storage::disk('s3')->temporaryUploadUrl(
'uploads/photo.jpg',
now()->addMinutes(5)
);

This is useful in serverless setups or when you want to keep your application servers out of the upload path.

Testing file uploads

Laravel ships with Storage::fake() and UploadedFile::fake() so you can write fast, isolated tests without touching real storage:

use Illuminate\\Http\\UploadedFile;
use Illuminate\\Support\\Facades\\Storage;

test('user can upload an avatar', function () {
Storage::fake('public');

$file = UploadedFile::fake()->image('avatar.jpg', 400, 400);

$response = $this->actingAs($this->user)->post('/upload', ['avatar' => $file]);

$response->assertRedirect();

Storage::disk('public')->assertExists('avatars/' . $file->hashName());
});

UploadedFile::fake()->image() generates a valid image file with the dimensions you specify. You can also create arbitrary file types:

UploadedFile::fake()->create('document.pdf', 1024, 'application/pdf');

The assertCount and assertDirectoryEmpty helpers are available on fake disks for more detailed assertions about what ended up in storage.

Best practices and security notes

A few things worth keeping in mind when building any file upload feature:

  • Never trust the client-supplied filename. Store files with a generated name (Laravel's default behaviour) rather than the original filename. The original name can contain path traversal sequences (../) or overwrite existing files.

  • Validate both extension and MIME type. An attacker can rename a PHP file to photo.jpg. Checking both mimes and mimetypes adds two independent layers of defense.

  • Keep private files off the public disk. If a file should only be visible to the owner, store it on the local disk and serve it through an authenticated controller that checks ownership before returning the file response.

  • Set upload_max_filesize and post_max_size in PHP. Laravel's max validation rule only fires if the file actually reaches your application. If the upload exceeds PHP's own limit, PHP silently drops it and $request->file() returns null. Align your PHP configuration with your application's expected limits.

  • Stream large files. Use putFile or putFileAs rather than reading file contents into a variable. Streaming avoids loading large files into memory and makes your application more resilient under load.

Conclusion

File uploads in Laravel 13 follow a predictable pattern: validate with FormRequest, store with store() or putFile(), serve public files via the symlinked storage URL, and protect private files behind signed routes or controller authorization. The Storage facade keeps the code consistent regardless of where files actually end up, so switching from local storage to S3 is a configuration change, not a code change.

 Join Our Monthly Newsletter

Get the latest news and popular articles to your inbox every month

We never send SPAM nor unsolicited emails

0 Comments

Leave a Reply

Your email address will not be published.

Replying to the message: View original

Hey visitor! Unlock access to featured articles, remove ads and much more - it's free.