Learn how to upload files in Laravel like a Pro
One of the things that a lot of people struggle with is uploading files. How do we upload a file to Laravel? What is the best way to upload a file? In this tutorial I will go from a basic version with blade and routes, going more advanced and then how we could do it in Livewire as well.
First, let’s look at how we could do this in standard Laravel and Blade. There are a few packages you can use for this – however I’m not a fan of installing a package for something as simple as uploading a file. Suppose you want to upload a file and associate it with a model and you have different media collections for your model. In that case, Spatie has a great package called MediaLibrary and MediaLibrary Pro that takes a lot of the hassle out of this process.
Let’s say we want to do this ourselves and not rely on a package for it. We want to create a form that allows us to upload and submit a file – and a controller accepts this form, validates the input, and processes the upload.
Before that though, let’s create a database table to store our uploads in. Consider a scenario where we want to upload and attach files to different models. We’d like to have a central spreadsheet for our media to attach to instead of uploading multiple versions for each model.
Let’s build this first using the following artisan command:
1php artisan make:model Media -m
This creates the model and migration that we can start with. Let’s take a look at the up method in migration so we understand what we want to store and understand:
1Schema::create('media', function (Blueprint $table) {
2 $table->id();
3
4 $table->string('name');
5 $table->string('file_name');
6 $table->string('mime_type');
7 $table->string('path');
8 $table->string('disk')->default('local');
9 $table->string('file_hash', 64)->unique();
10 $table->string('collection')->nullable();
11
12 $table->unsignedBigInteger('size');
13
14 $table->timestamps();
15});
Our media requires a name so we can extract the client’s original name from the upload. We then want a filename, which will be a generated name. Saving uploaded files with the original filename can be a significant security issue, especially if you don’t validate strongly enough. The mime type is then required so that we can understand what has been uploaded, whether it is a CSV file or an image. The path to the upload is also handy to store because it’s easier for us to reference. We’re plotting the disk we’re saving this to so we can work with it dynamically from within Laravel. However, we could interact with our application. We store the file hash as a unique column to ensure we don’t upload the same file more than once. If the file changes, this would be a new variant and can be uploaded again. Finally we have Collection and Size where we can store a file in a collection like “Blogposts” which creates a virtual directory/taxonomy structure. The size is mainly for informational purposes, but allows you to ensure that your digital assets are not too large.
Now we know where we want to save these uploads and can check how we want to upload them. We’ll start with a simple implementation in a route/controller and expand from there.
Let’s create our first controller with the following artisan command:
1php artisan make:controller UploadController --invokable
Here, for now, we route uploads to an invocable controller that handles the file upload synchronously. Add this as a route in your web.php
so:
1Route::post('upload', App\Http\Controllers\UploadController::class)->name('upload');
Then we can look at how this process should work. First of all, like all other endpoints, we want to validate the input early. I like to do this in a form request as it keeps things well encapsulated. You can do this part as you see fit; I’ll show you the following rules I use:
1use Illuminate\Validation\Rules\File;
2
3return [
4 'file' => [
5 'required',
6 File::types(['png', 'jpg'])
7 ->max(5 * 1024),
8 ]
9];
So we have to send a file
in our request, and it must be either a PNG or JPG and no larger than 5 GB. You can use config to save your default rules for it if you find it more accessible. However, I usually create a specific validator class for each use case, for example:
1class UserUploadValidator
2{
3 public function avatars(): array
4 {
5 return [
6 'required',
7 File::types(['png', 'jpg'])
8 ->max(5 * 1024),
9 ];
10 }
11}
Once you have your validation set up, you can handle this in your controller as needed. Suppose I use a form request and put it in my controller. Now that we have validated, we need to process. My general approach to controllers is:
In an API, I’m doing background processing, which usually means I’m submitting a job—but on the web, that’s not always convenient. Let’s look at how we might process a file upload.
1class UploadController
2{
3 public function __invoke(UploadRequest $request)
4 {
5 Gate::authorize('upload-files');
6
7 $file = $request->file('file');
8 $name = $file->hashName();
9
10 $upload = Storage::put("avatars/{$name}", $file);
11
12 Media::query()->create(
13 attributes: [
14 'name' => "{$name}",
15 'file_name' => $file->getClientOriginalName(),
16 'mime_type' => $file->getClientMimeType(),
17 'path' => "avatars/{$name}"
18,
19 'disk' => config('app.uploads.disk'),
20 'file_hash' => hash_file(
21 config('app.uploads.hash'),
22 storage_path(
23 path: "avatars/{$name}",
24 ),
25 ),
26 'collection' => $request->get('collection'),
27 'size' => $file->getSize(),
28 ],
29 );
30
31 return redirect()->back();
32 }
33}
First of all, we make sure that the logged-in user has permission to upload files. Then we want to upload the file and save the hashed name. We then upload the file and save the record in the database, getting the information we need for the model from the file itself.
I would call this the standard approach to uploading files, and I’ll admit that there’s nothing wrong with that approach. If your code already looks something like this, you’re doing a good job. However, we can of course continue this – in different ways.
The first way to achieve this is to extract the upload logic into a UploadService
where it generates everything we need and returns a domain transfer object (which I call data objects) so we can use the object’s properties to build the model. First, let’s create the object that we want to return.
1class File
2{
3 public function __construct(
4 public readonly string $name,
5 public readonly string $originalName,
6 public readonly string $mime,
7 public readonly string $path,
8 public readonly string $disk,
9 public readonly string $hash,
10 public readonly null|string $collection = null,
11 ) {}
12
13 public function toArray(): array
14 {
15 return [
16 'name' => $this->name,
17 'file_name' => $this->originalName,
18 'mime_type' => $this->mime,
19 'path' => $this->path,
20 'disk' => $this->disk,
21 'file_hash' => $this->hash,
22 'collection' => $this->collection,
23 ];
24 }
25}
Now we can look at the upload service and find out how it is supposed to work. Looking at the logic inside the controller, we know that we want to generate a new name for the file and keep the original name of the upload. Then we want to save the file and return the data object. As with most code I write, the service should implement an interface that we can then bind to the container.
1class UploadService implements UploadServiceContract
2{
3 public function avatar(UploadedFile $file): File
4 {
5 $name = $file->hashName();
6
7 $upload = Storage::put("{$name}", $file);
8
9 return new File(
10 name: "{$name}",
11 originalName: $file->getClientOriginalName(),
12 mime: $file->getClientMimeType(),
13 path: $upload->path(),
14 disk: config('app.uploads.disk'),
15 hash: file_hash(
16 config('app.uploads.hash'),
17 storage_path(
18 path: "avatars/{$name}",
19 ),
20 ),
21 collection: 'avatars',
22 );
23 }
24}
Now let’s refactor our UploadController to use this new service:
1class UploadController
2{
3 public function __construct(
4 private readonly UploadServiceContract $service,
5 ) {}
6
7 public function __invoke(UploadRequest $request)
8 {
9 Gate::authorize('upload-files');
10
11 $file = $this->service->avatar(
12 file: $request->file('file'),
13 );
14
15 Media::query()->create(
16 attributes: $file->toArray(),
17 );
18
19 return redirect()->back();
20 }
21}
Suddenly our controller is much cleaner and our logic has been extracted into our new service – so it’s repeatable no matter where we need to upload a file. Of course we can write tests for that too, because why do something you can’t test?
1it('can upload an avatar', function () {
2 Storage::fake('avatars');
3
4 $file = UploadedFile::fake()->image('avatar.jpg');
5
6 post(
7 action(UploadController::class),
8 [
9 'file' => $file,
10 ],
11 )->assertRedirect();
12
13 Storage::disk('avatars')->assertExists($file->hashName());
14});
We fake the storage facade, create a fake file to upload, and then reach our endpoint and send the file. We then claimed that everything was ok and we were redirected. In conclusion, we would like to assert that the file now exists on our hard drive.
How could we continue this? Depending on the application, we get down to business here. For example, let’s say that your application has many different types of uploads that you may need to perform. We want our upload service to reflect that without getting too complicated, right? At this point I’m using a pattern I call the “service action pattern” where our service calls an action instead of handling the logic. This pattern allows you to inject a single service, but call multiple actions through it – keeping your code clean and focused, and your service just a handy proxy.
First let’s create the action:
1class UploadAvatar implements UploadContract
2{
3 public function handle(UploadedFile $file): File
4 {
5 $name = $file->hashName();
6
7 Storage::put("{$name}", $file);
8
9 return new File(
10 name: "{$name}",
11 originalName: $file->getClientOriginalName(),
12 mime: $file->getClientMimeType(),
13 path: $upload->path(),
14 disk: config('app.uploads.disk'),
15 hash: hash_file(
16 config('app.uploads.hash'),
17 storage_path(
18 path: "avatars/{$name}",
19 ),
20 ),
21 collection: 'avatars',
22 size: $file->getSize(),
23 );
24 }
25}
Now we can refactor our service to invoke the action and act as a useful proxy.
1class UploadService implements UploadServiceContract
2{
3 public function __construct(
4 private readonly UploadContract $avatar,
5 ) {}
6
7 public function avatar(UploadedFile $file): File
8 {
9 return $this->avatar->handle(
10 file: $file,
11 );
12 }
13}
This feels like over-engineering for a small application. However, for broader media-centric applications, you can handle uploads through a service that can be well documented, rather than having fragmented knowledge across your entire team.
Where can we take it from here? Let’s get into userland for a moment and assume we’re using the TALL stack (because why wouldn’t you!?). At Livewire we have a slightly different approach where Livewire handles the upload for you and saves it as a temporary file which allows you to work with a slightly different API when saving the file.
First we need to create a new Livewire component that we can use for our file upload. You can create this with the following artisan command:
1php artisan livewire:make UploadForm --test
Now we can add some properties to our component and add a property to let the component know it’s handling file uploads.
1final class UploadForm extends Component
2{
3 use WithFileUploads;
4
5 public null|string|TemporaryUploadedFile $file = null;
6
7 public function upload()
8 {
9 $this->validate();
10 }
11
12 public function render(): View
13 {
14 return view('livewire.upload-form');
15 }
16}
Livewire has a handy feature that allows us to work with file uploads with ease. We have a file property that could be null, a string for a path or an uploaded temporary file. That’s maybe the one part I don’t like about file uploads in Livewire.
Now that we have a basic component at hand, let’s look at how to move the logic from our controller to the component. One thing we would do here is move the gate check from the controller to the UI so we don’t show the form when the user can’t upload files. This simplifies our component logic nicely.
Our next step is to inject the UploadService
into our upload method, which Livewire can resolve for us. Besides that, we want to do our validation immediately. Our component should not look like this:
1final class UploadForm extends Component
2{
3 use WithFileUploads;
4
5 public null|string|TemporaryUploadedFile $file;
6
7 public function upload(UploadServiceContract $service)
8 {
9 $this->validate();
10 }
11
12 public function rules(): array
13 {
14 return (new UserUploadValidator())->avatars();
15 }
16
17 public function render(): View
18 {
19 return view('livewire.upload-form');
20 }
21}
Our confirmation rules
method returns our avatar validation rules from our validation class and we’ve injected the service from the container. Next we can add our logic for actually uploading the file.
1final class UploadForm extends Component
2{
3 use WithFileUploads;
4
5 public null|string|TemporaryUploadedFile $file;
6
7 public function upload(UploadServiceContract $service)
8 {
9 $this->validate();
10
11 try {
12 $file = $service->avatar(
13 file: $this->file,
14 );
15 } catch (Throwable $exception) {
16 throw $exception;
17 }
18
19 Media::query()->create(
20 attributes: $file->toArray(),
21 );
22 }
23
24 public function rules(): array
25 {
26 return (new UserUploadValidator())->avatars();
27 }
28
29 public function render(): View
30 {
31 return view('livewire.upload-form');
32 }
33}
We only need minimal changes to how our logic works – we can put it almost straight into place and it will work.
This is how uploading files works for me; There are of course many ways to do the same thing – and some/most of them are a bit simpler. It wouldn’t be a Steve tutorial if I didn’t go a little headstrong and overboard, would it?
What’s your favorite way to handle file uploads? Found a way that works well for your use case? Let us know on Twitter!