• Reading time ~ 21 min
  • 10.03.2023

When building a web application, you'll often need to add a feature that allows users to upload files. For example, you might want to allow users to update their profile pictures or share files with each other.

In this article, we're going to take a look at how to use a JavaScript library called "FilePond" to upload files in your Laravel applications. We'll also briefly discuss alternative approaches to file uploads in Laravel. We'll then take a look at how you can use FilePond to upload multiple files at once and upload images (with an image preview). Finally, we'll then look at how you can validate your uploaded files, how to delete temporary files, and how to write tests for your file uploading code.

What is FilePond?

FilePond is a JavaScript library that allows you to upload files in your web applications.

It provides a simple, accessible, and visually-appealing interface for uploading files. It can give you a great starting point for building your own file upload functionality without needing to worry too much about the styling and accessibility of the interface.

FilePond allows you to upload synchronously or asynchronously. This means that you can either upload the files in a single request at the time of submitting the form (synchronous), or you can upload the files in separate requests before you submit the form (asynchronous). Using the asynchronous approach can typically provide a better user experience as users can continue to fill out other fields in the form while the files are being uploaded. For the purpose of this article, we're going to focus on the asynchronous approach.

There are also a number of plugins that you can use with FilePond to add additional functionality. For example, you can use the FilePondPluginImagePreview plugin to show a preview of the image that's being uploaded. In fact, we'll be taking a look at this plugin later in this article.

FilePond also provides the ability to upload files in chunks. This is useful if you want to upload larger files that may be too large to upload in a single request. However, for the sake of this tutorial, we'll only be looking at uploading files in a single request. If you'd like more information on how to upload files in chunks, you can check out the FilePond documentation.

How Does the FilePond Asynchronous File Upload Flow Work?

To explain how a user might upload a file in a form asynchronously using FilePond, let's take a look at an example. We'll imagine the user is updating their profile, using a form that allows them to update their name and profile picture. We'll assume the user wants to upload an avatar.png file as their new profile picture.

The flow may work something like so:

  1. The user clicks "Browse" in the FilePond component on the form.
  2. The traditional file upload dialog box appears so the user can choose the avatar.png file they want to upload from their device.
  3. Once the file is selected, FilePond sends the avatar.png file to the server as multipart/form-data using a POST request.
  4. The server (our Laravel application) then saves the file to a temporary, unique location. For example, it might save the file to tmp/12345abcdef/avatar.png.
  5. The server then returns the unique location (in this case, 12345abcdef/avatar.png) in a text/plain response back to FilePond.
  6. FilePond add this unique location in a hidden input field on the form.
  7. While steps 3-6 were running the user could have continued to fill out the rest of the form while the file was uploading. Once the file has finished uploading, the user can then submit the form (which now includes the hidden input field).
  8. The server (our Laravel application) uses the unique location to move the file from the temporary storage location to its intended location. For example, it might move the file from tmp/12345abcdef/avatar.png to avatars/user-1.png.

Now that we've got a brief idea of how asynchronous file uploads work, let's take a look at their advantages over synchronous file uploads in forms.

Synchronous File Uploads Block the UI

Typically, in a web application when using a synchronous approach for uploading files, a user might click on a "file upload" field in a form. They then might choose which file they'd like to upload. Once they've chosen their file, the file isn't actually uploaded to the server until the user submits the form (unlike the asynchronous approach we saw above). This means that the file is uploaded in a single request (with the rest of the form fields) when the form is submitted.

Using this synchronous approach can sometimes block the user from interacting with the UI. This is particularly true if the file is large and takes a long time to upload because the user won't have much feedback about what's happening.

This is different to how asynchronous file uploads work. In the asynchronous approach, the file would have already been uploaded to the server (or would be in the progress of being uploaded) in a separate request before the form is submitted.

Synchronous File Uploading Issues with Serverless Platforms

If you're running your application on a serverless platform, such as AWS Lambda, synchronous file uploads can quickly become problematic. At the time of writing this article, according to the AWS Lambda documentation, the maximum size of a request is 6MB. This means you'll need to make sure the size of the data in your form (including your uploaded files) doesn't exceed this limit.

This means that you need to adopt an asynchronous approach for uploading files if you intend to run your applications on a serverless platform. Depending on your application, you may want to upload them directly to your storage provider (such as AWS S3) from your browser. As a result of doing this, it means that you can avoid the files touching your server at all. Not only can this be more secure (because you avoid potentially malicious files from being processed on your server), but it can also be more performant (because you don't need to upload the files to your server first), and allows you to avoid the 6MB limit.

Although the general principles covered in this article could be applied to uploading files directly to your storage provider, we're going to focus on uploading files to your server first, and then moving them to your storage provider. However, if you're using Laravel Vapor, you can check out the documentation to find out more about how to upload files directly to your AWS S3 bucket.

Setting Up FilePond on the Front End

Now that we've got an understanding of the asynchronous file upload flow, let's take a look at how we can set up FilePond on the front end of our Laravel application.

FilePond provides several adapters that you can use with different frameworks, such as Vue, React, and Angular. However, in this article, we'll just be using the vanilla JavaScript adapter.

We'll make the assumption that we're working on a fresh Laravel installation that's using Vite to compile the assets.

Let's take a basic example. We're going to imagine that we're building a CSV import feature that allows users to upload a CSV file containing product details that will be created in our web app.

To begin with, let's make a very basic Blade view that contains a form with a single "file" input field:

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">

        <title>FilePond Tutorial</title>

        <meta name="csrf-token" content="{{ csrf_token() }}">

        @vite('resources/js/app.js')
    </head>

    <body>
        <form action="{{ route('products.import') }}" method="POST">
            @csrf
            <input type="file" name="csv" class="filepond"/>

            <button type="submit">Import Products</button>
        </form>
    </body>
</html>

Now, let's install FilePond via NPM by running the following command:

npm i filepond --save

We can then open our resources/js/app.js file and add the functionality to enable FilePond on our input field:

import * as FilePond from 'filepond';
import 'filepond/dist/filepond.min.css';

const inputElement = document.querySelector('input[type="file"].filepond');

const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');

FilePond.create(inputElement).setOptions({
    server: {
        process: './uploads/process',
        headers: {
            'X-CSRF-TOKEN': csrfToken,
        }
    }
});

Let's take a quick look at what's being done in the above code. First, we're importing the FilePond JavaScript and CSS files that provide the functionality and styles we need.

We've then moved on to find the input field that we want to convert to a FilePond field. Notice how we've added the filepond class to the query selector. This is so that we can distinguish between the input fields that we want to convert to FilePond fields, and the ones that we might not want to.

We've then grabbed the CSRF token from the meta tag we added to the Blade view. This is so that we can pass it to FilePond so it can be sent to our server when attempting to upload a file. Without adding this, you will receive an HTTP 419 error response whenever you try to upload a file.

We've then created our FilePond instance and specified that when we want to upload a new file, it should be sent to a /uploads/process URL on our server. FilePond also provides the functionality for us to specify a URL for deleting temporarily uploaded files, but we won't be using this functionality in this tutorial.

The front end should now be ready for using. If a user was to select a CSV file, it would be sent to the /uploads/process URL and temporarily stored. A hidden csv field in the form would then be populated with the file path where we have temporarily stored the file.

Setting Up FilePond on the Back End

We can now set up the back end of our Laravel application to handle the file uploads coming from FilePond. To do this we'll need to create a route and controller that are responsible for temporarily storing the uploaded files.

As I mentioned earlier, FilePond does provide the ability to upload files in chunks. But for the purpose of this tutorial, we're going to keep things simple and only look at uploading files in a single request.

We'll first start by creating a new FileUploadController by running the following command:

php artisan make:controller FileUploadControlle

We can then add a process method to the controller that handles the file upload and stores the file in a tmp directory in storage:

declare(strict_types=1);

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Str;

final class FileUploadController extends Controller
{
    public function process(Request $request): string
    {
        // We don't know the name of the file input, so we need to grab
        // all the files from the request and grab the first file.
        /** @var UploadedFile[] $files */
        $files = $request->allFiles();

        if (empty($files)) {
            abort(422, 'No files were uploaded.');
        }
        if (count($files) > 1) {
            abort(422, 'Only 1 file can be uploaded at a time.');
        }
        // Now that we know there's only one key, we can grab it to get
        // the file from the request.
        $requestKey = array_key_first($files);

        // If we are allowing multiple files to be uploaded, the field in the
        // request will be an array with a single file rather than just a
        // single file (e.g. - `csv[]` rather than `csv`). So we need to
        // grab the first file from the array. Otherwise, we can assume
        // the uploaded file is for a single file input and we can
        // grab it directly from the request.
        $file = is_array($request->input($requestKey))
            ? $request->file($requestKey)[0]
            : $request->file($requestKey);

        // Store the file in a temporary location and return the location
        // for FilePond to use.
        return $file->store(
            path: 'tmp/'.now()->timestamp.'-'.Str::random(20)
        );
    }
}

You may have noticed that we also added support for form fields that accept multiple file uploads. We'll cover how to set up FilePond on the front end to also support multiple file uploads later in this article.

If a user uploads a file to this controller, a string similar to the following will be returned:

tmp/1678198256-88eXsQV7XB2RU5zXdw0S/9A4eK5mRLAtayW78jhRo3Lc3WdSSrsihpVHhMvzr.png

We can then register the /uploads/process route in our web.php file like so:

use App\Http\Controllers\FileUploadController;
use Illuminate\Support\Facades\Route;

Route::post('uploads/process', [FileUploadController::class, 'process'])->name('uploads.process');

Your application should now be successfully uploading files and storing them in a temporary directory.

Accessing the Uploaded File in the Controller

Now that we've set up FilePond on the front end and added the functionality to temporarily store the files on the backend, we can now look at how to access the uploaded files in our controllers when the form is submitted.

We'll start by creating a new controller that is responsible for importing the products from the CSV file. We can do this by running the following command:

php artisan make:controller ImportProductController -i

We can then update our newly created ImportProductController to handle the file imports:

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Services\ProductImportService;
use Illuminate\Http\File;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;

final class ImportProductController extends Controller
{
    public function __invoke(
        Request $request,
        ProductImportService $productImportService
    ): RedirectResponse {
        $validated = $request->validate([
            'csv' => 'required|string',
        ]);
        // Copy the file from a temporary location to a permanent location.
        $fileLocation = Storage::putFile(
            path: 'imports',
            file: new File(Storage::path($validated['csv']))
        );
        $productImportService->import(
            csvLocation: $fileLocation
        );
        return redirect()
            ->route('products.index')
            ->with('success', 'Products imported successfully');
    }
}

Let's take a look at what's being done in the controller method above.

First, we've added a type hint for a ProductImportService class so that it will be resolved from the service container for us to use in our controller method. This isn't a class that we'll be looking at in this article, but we can assume that it's responsible for importing the products from the CSV file.

We're also validating that the request contains a csv string field. We'll look at how we can improve this validation later in the article.

Following this, we're then copying the file from its temporary location to a permanent location so we can pass it to our ProductImportService object.

After this has all been done, we then return a redirect response to the products index page with a success message.

We can now register the route for our ImportProductController in our web.php file like so:

use App\Http\Controllers\ImportProductController;

Route::post('products/import', ImportProductController::class)->name('products.import');

Uploading Images

FilePond provides a very handy FilePondPluginImagePreview plugin that allows us to show a preview of the image that the user has selected to upload. I think this is a really nice touch and looks great. It also provides some feedback to the user about the file they've chosen to upload so they can confirm it's the correct one.

To use the FilePondPluginImagePreview plugin, we can install it via NPM by running the following command:

npm i filepond-plugin-image-preview --save

Once it's installed we can then import the following lines into our app.js file:

import FilePondPluginImagePreview from 'filepond-plugin-image-preview';
import 'filepond-plugin-image-preview/dist/filepond-plugin-image-preview.css';

Following this, we can then use the registerPlugin method to register the plugin with FilePond:

FilePond.registerPlugin(FilePondPluginImagePreview);

After adding these lines, your code may look something like so:

import * as FilePond from 'filepond';
import 'filepond/dist/filepond.min.css';
import FilePondPluginImagePreview from 'filepond-plugin-image-preview';
import 'filepond-plugin-image-preview/dist/filepond-plugin-image-preview.css';

const inputElement = document.querySelector('input[type="file"].filepond');

const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');

FilePond.registerPlugin(FilePondPluginImagePreview);

FilePond.create(inputElement).setOptions({
    server: {
        process: './uploads/process',
        headers: {
            'X-CSRF-TOKEN': csrfToken,
        }
    },
    allowMultiple: true,
});

That's it! You should now have a working FilePond component that allows you to upload images and preview them.

Uploading Multiple Files

There may be times that you want to upload multiple files at once in one form submission. For example, you may want to upload several images for a single product.

To do this, we can add the multiple attribute to our input element:

<input type="file" name="csv[]" class="filepond" multiple/>

We can then pass allowMultiple: true to the setOptions method:

FilePond.create(inputElement).setOptions({
    server: {
        process: './uploads/process',
        fetch: null,
        revert: null,
        headers: {
            'X-CSRF-TOKEN': csrfToken,
        }
    },
    allowMultiple: true,
});

That's all there is to it! We already made sure that our FileUploadController could handle multiple files, so we don't need to make any changes to it.

If a user was to attempt to upload two files, two separate requests would be made to the server to store the files. There would then be two csv[] hidden fields added to the form with the file names of the uploaded files.

Notice how we need to use csv[] rather than csv. This is because if we used csv, we would only be able to send a single file path each time the form is submitted. By using csv[], we can send multiple file paths which can then be accessed in our controller as an array of strings.

Taking it Further

Now that we've looked at how we can upload files in our Laravel applications using FilePond, let's take a look at some other things you'll likely want to do.

Validation

Filepond provides some helpers that you can use to add validation to your file upload component, such as data-max-file-size. You can add these validation helpers to your input element like so:

<input type="file" name="csv" class="filepond" data-max-file-size="3MB"/>

However, it's important to remember that client-side validation is mainly intended for UI/UX purposes rather than security. You should always validate your data on the server side as well to ensure that the data is valid.

For this reason, it's very important to validate the file after submitting the form before trying to process it.

For example, let's imagine that we provide the functionality for a user to update their profile picture. You wouldn't want this field to accept a CSV file. Instead, we'd want to make sure that the file is an image.

So, let's take a look at how we can write a validation rule to ensure that the uploaded file is valid. We'll start by creating a new validation rule by running the following command:

php artisan make:rule ValidFileUpload

We can update our ValidFileUpload rule to look like so:

declare(strict_types=1);

namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\Storage;

final class ValidFileUpload implements ValidationRule
{
    public function __construct(
        private readonly array $validMimeTypes
    ) {
        //
    }
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if (!Storage::exists($value)) {
            $fail('The file does not exist.');
        }
        if (!in_array(Storage::mimeType($value), $this->validMimeTypes, true)) {
            $fail('The file is not a valid mime type.');
        }
    }
}

In the ValidFileUpload class, we have defined a constructor that accepts an array of valid mime types.

In the validate method, we've then added two checks:

  1. Check the file exists in storage.
  2. Check the file's mime type is in the array of valid mime types.

We can then use this rule for validating like so:

use App\Rules\ValidFileUpload;

$validated = $request->validate([
    'csv' => ['required', 'string', new ValidFileUpload(['text/csv'])],
]);

You could even take this validation a step further and add extra assertions, such as checking the file size doesn't exceed a certain size.

Cleaning Up Temporary Files

Over time, a large number of temporary files can build up in your tmp folder. So, you may want to write an Artisan command that you can schedule to run regularly to delete folders from the tmp folder that are older than a certain amount of time.

Let's take a look at how we can do this. We'll start by creating a new DeleteTempUploadedFiles command by running the following command:

php artisan make:command DeleteTempUploadedFiles

We can then update our DeleteTempUploadedFiles command to look something like so:

declare(strict_types=1);

namespace App\Console\Commands;

use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;

final class DeleteTempUploadedFiles extends Command
{
    protected $signature = 'app:delete-temp-uploaded-files';

    protected $description = 'Delete temporary uploaded files older than 24 hours.';

    public function handle(): void
    {
        foreach (Storage::directories('tmp') as $directory) {
            $directoryLastModified = Carbon::createFromTimestamp(Storage::lastModified($directory));

            if (now()->diffInHours($directoryLastModified) > 24) {
                Storage::deleteDirectory($directory);
            }
        }
    }
}

In the command above, we're looping through all the directories in your storage's tmp folder and checking if the directory is older than 24 hours. If it is, we then delete the directory.

We can then schedule this command to run every hour by adding it to the schedule method in the app/Console/Kernel.php class:

namespace App\Console;

use App\Console\Commands\DeleteTempUploadedFiles;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;

class Kernel extends ConsoleKernel
{
    /**
     * Define the application's command schedule.
     */
    protected function schedule(Schedule $schedule): void
    {
        $schedule->command(DeleteTempUploadedFiles::class)->hourly();
    }
    // ...
}

Assuming you have your application's scheduler running, this means that every hour, your app will delete any temporary directories that are older than 24 hours. This means that your tmp folder should only ever contain files that may have been recently used, or may currently be being used.

Depending on your application, you may want to change the length of time a directory can exist, or how regularly they are deleted.

Testing Your Code

If you've read any of my articles before, you'll know I'm a big fan of testing. It's important that your code has tests written for it, especially if you're going to be using it in production. It helps to give you confidence that your code works correctly, and makes it easier to make changes in the future.

Let's take a look at how we could write some basic tests for our file upload functionality in our FileUploadController. At a high level, we want to test that:

  • A file can be stored in the tmp folder if the form field supports a single file.
  • A file can be stored in the tmp folder if the form field supports multiple files.
  • An error is returned if no file is passed in the request.
  • An error is returned if more than one file is passed in the request.

We could write some basic tests to cover these scenarios like so:

declare(strict_types=1);

namespace Tests\Feature\Controllers;

use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Tests\TestCase;

final class FileUploadControllerTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();

        // Use a fake storage driver so we don't store files on the real disk.
        Storage::fake();

        // Freeze time and define how `Str::random` should work. This allows us
        // to explicitly check that the file is stored in the correct location
        // and is being named correctly.
        $this->freezeTime();
        Str::createRandomStringsUsing(static fn (): string => 'random-string');
    }
    /** @test */
    public function file_can_be_temporarily_uploaded_for_a_single_file_field(): void
    {
        $file = UploadedFile::fake()->image('avatar.png');

        $expectedFilePath = 'tmp/'.now()->timestamp.'-random-string';

        $this->post(route('uploads.process'), [
            'avatar' => $file,
        ])
            ->assertOk()
            ->assertSee($expectedFilePath);

        Storage::assertExists($expectedFilePath);
    }
    /** @test */
    public function file_can_be_temporarily_uploaded_for_a_multiple_file_field(): void
    {
        $file = UploadedFile::fake()->image('avatar.png');

        $expectedFilePath = 'tmp/'.now()->timestamp.'-random-string';

        $this->post(route('uploads.process'), [
            'avatar' => [
                $file
            ],
        ])
            ->assertOk()
            ->assertSee($expectedFilePath);

        Storage::assertExists($expectedFilePath);
    }
    /** @test */
    public function error_is_returned_if_no_file_is_passed_in_the_request(): void
    {
        $this->post(route('uploads.process'))
            ->assertStatus(422);
    }
    /** @test */
    public function error_is_returned_if_more_than_one_file_is_passed_in_the_request(): void
    {
        $file = UploadedFile::fake()->image('avatar.png');

        $this->post(route('uploads.process'), [
            'avatar' => $file,
            'invalid' => $file,
        ])
            ->assertStatus(422);
    }
}

Although these tests are fairly basic, they should give you a good starting point to write your own tests for your file upload functionality. You may want to expand on these tests to check the correct error message is returned. Or, you might want to check that only certain users are permitted to upload files if you add authentication and authorisation to your file uploading flow.

You may also want to add tests for your validation rule too. This could help you to have more confidence in adding more assertions in the future if you decide to make your validation stricter.

Conclusion

In this article, we've taken a look at how to use FilePond to asynchronously upload files in your Laravel applications. We've also looked at how you can delete your temporary files, validate your uploaded files, and write tests to make sure your file uploads work.

Hopefully, you should now feel confident enough to implement this same approach in your own Laravel projects to add file-uploading functionality to your applications.

Comments

CrazyBoy49z
CrazyBoy49z 11.03.2023 00:19

test comment

Yurij Finiv

Yurij Finiv

Full stack

ABOUT

Professional Fullstack Developer with extensive experience in website and desktop application development. Proficient in a wide range of tools and technologies, including Bootstrap, Tailwind, HTML5, CSS3, PUG, JavaScript, Alpine.js, jQuery, PHP, MODX, and Node.js. Skilled in website development using Symfony, MODX, and Laravel. Experience: Contributed to the development and translation of MODX3 i...

About author CrazyBoy49z
WORK EXPERIENCE
Contact
Ukraine, Lutsk
+380979856297