Multi-Image Uploader with Laravel, JavaScript & Tailwind

Laravel 02 May 2022 7 minute read

A step-by-step guide on creating a multi-image uploader using Laravel, JavaScript & Tailwind.

Browse the code on GitHub.

Uploading images to the web is hard. Luckily for us, Laravel makes dealing with image upload really simple. That being said, it's a fair bit of effort creating the JavaScript to handle the UI, so that's what we'll be focusing on today.

I recently had to build a multi-image uploader for a WordPress site. I set everything up in a very "Laravel-like" way so I had some familiarity when dealing with the $_POST and $_FILES request arrays (I ended up building my own custom Request class!).

The work on that WordPress site is what inspired this post. If you'd like to know more about how I make my WordPress sites more Laravel-like, give me a shout.


1. Create A Route For Handling Uploads

To create a new route for handling our image uploads, open your routes/web.php file. If you're doing this in a fresh Laravel app you'll see only one route, the home route.

Create a new post route that goes to /gallery-upload:

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::post('gallery-upload', function (Request $request) {
    //
});

First we'll need to add some validation to our route to make sure we only accept a single image:

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::post('gallery-upload', function (Request $request) {
    $validated = $request->validate([
        'image' => [
            'required',
            'image',
            'mimes:jpg,jpeg,png,gif',
            'max:2048',
        ],
    ]);
});

If the image upload fails, or someone tries to upload an image type we don't accept, Laravel will return a a 422 error. We'll handle this error later on with Axios.

Now we need to actually store the image. Thankfully, Laravel makes it super simple with the Illuminate\Support\Facades\Storage facade.

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Storage;

Route::post('gallery-upload', function (Request $request) {
    $validated = $request->validate([
        'image' => [
            'required',
            'image',
            'mimes:jpg,jpeg,png,gif',
            'max:2048',
        ],
    ]);

    Storage::disk('public')->put('/uploads', $validated['image']);
});

This will save the image in the storage/app/public/uploads directory.

If you need a more dedicated storage solution, it's worth looking at something like an S3 compatible filesystem. Laravel has support for that.


2. Create the Form HTML

I won't bore you by going in-depth on all the Tailwind classes used for the page and form layout. It's actually just a slightly tweaked version of an example from the Tailwind UI site.

Here's the exact HTML markup with Tailwind CSS classes if you'd like it:

<div class="relative flex items-top justify-center min-h-screen sm:items-center p-10 sm:pt-0">
    <div class="md:grid md:grid-cols-3 md:gap-6 max-w-7xl">
        <div class="md:col-span-1">
            <div class="px-4 sm:px-0">
                <h2 class="text-2xl font-medium leading-6 text-gray-900">Multi-Image Uploader</h2>
                <p class="mt-5 text-lg text-gray-600">Select some images and watch them upload with Axios.</p>
            </div>
        </div>
        <div class="mt-5 md:mt-0 md:col-span-2">
            <form action="#" method="POST">
                <div class="shadow sm:rounded-md sm:overflow-hidden">
                    <div class="px-4 py-5 bg-white space-y-6 sm:p-6">
                        <label for="file-upload"
                            class="block text-lg font-medium text-gray-700">Images</label>
                        <div class="mt-3">
                            <input id="gallery-uploader"
                                class="border-solid border-2 border-gray-300 rounded-md p-1.5"
                                name="gallery" type="file" accept="image/png,image/gif,image/jpeg"
                                multiple>
                        </div>

                        <div class="upload-previews"></div>
                    </div>
                </div>
            </form>
        </div>
    </div>
</div>

3. Install Axios via Yarn

Now let's install Axios. You can do so via Yarn or NPM.

I'm a big fan of Yarn. I find it easier to use and a fair bit quicker than NPM when it comes to installing and updating packages.

# Install via Yarn
yarn add axios

# Install via NPM
npm install axios

4. Add SVGs to JavaScript

In your JavaScript file (in Laravel the default is resources/js/app.js) we're going to add some SVG code to use in our image preview template in the next step.

const svgs = {
    inProgress: `
        <svg class="animate-spin h-5 w-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
            <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
            <path fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
        </svg>`,
    done: `
        <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
            <path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
        </svg>`,
    error: `
        <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
            <path stroke-linecap="round" stroke-linejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
        </svg>`,
};

These icons are from the Heroicons set.


5. Create JavaScript Template Literals

We're going to need some JavaScript HTML templates to display our image previews, both before and after they're uploaded.

In JavaScript, you can use template literals to add HTML markup. This gives you both the ability to format it properly with line-breaks but also, and arguably more importantly, it gives you the ability to use variables inside a string, like so:

let text = "an embedded variable"
let htmlMarkup = `Some markup with ${text}.`;

// Output: Some markup with an embedded variable

Here's the template for the image preview (with a couple of possible states):

/**
 * @param {HTMLInputElement} file
 * @param {String} image
 * @param {Number} index
 * @param {String} status
 *
 * @returns {String}
 */
function filePreviewTemplate(file, image, index, status = 'inProgress') {
    let icon = svgs[status] ?? '';
    let cssClasses = '';

    if (status === 'error') {
        cssClasses = 'text-red-500';
    } else if (status === 'done') {
        cssClasses = 'text-green-500';
    }

    return `
        <div class="mt-1 ${cssClasses}" data-upload-preview="${index}">
            <div class="mb-2.5 flex items-center">
                <div class="mr-2.5 shrink-0">
                    <span data-upload-preview-icon="${index}">
                        ${icon}
                    </span>
                </div>

                <img class="mr-2.5 shrink-0 w-14 h-14" src="${image}" alt="">

                <div class="flex items-center w-full">
                    <p class="text-base">
                        ${file.name}
                    </p>
                </div>
            </div>
        </div>
    `;
}

You'll see we pass some parameters to the function:

  • file - A HTML input element
  • image - A single image object. We'll get this from the file input later.
  • index - The index of the file in the files array.
  • status - Defaultd to inProgress, but can also be error or done.

6. Append Image Previews to Page

We're going to create an uploadImages function which will do the bulk of the work.

It's going to accept a single parameter which is the array of file objects from the input.

/**
 * @param {File[]} files
 */
function uploadImages(files) {
    if (!files.length) {
        return;
    }

    for (let i = 0; i < files.length; i++) {
        let file = files[i];

        let previewsElement = document.querySelector('.upload-previews');
        let image = URL.createObjectURL(file);

        previewsElement.innerHTML += filePreviewTemplate(file, image, i, 'inProgress');
    }
}

The above code loops through each file, then appends it the innerHTML property of the .upload-previews element we have set up to display them inside of.

You'll see the div in the form template from step 2, <div class="upload-previews"></div>.

Our template function is then getting some arguments passed, the ones we discussed in the last step, and returning the corresponding HTML.


7. Upload Images via Axios

It's all well and good showing the previews on the page, but what's the point if they don't actually upload anywhere?

Let's add a small snippet of Axios code to the end of each foreach iteration to handle the uploading of images:

/**
 * @param {File[]} files
 */
function uploadImages(files) {
    if (!files.length) {
        return;
    }

    for (let i = 0; i < files.length; i++) {
        let file = files[i];

        let previewsElement = document.querySelector('.upload-previews');
        let image = URL.createObjectURL(file);

        previewsElement.innerHTML += filePreviewTemplate(file, image, i, 'inProgress');

        let formData = new FormData();
        formData.append('image', file);

        axios.post('/gallery-upload', formData)
            .then(response => {
                previewsElement.querySelector(`[data-upload-preview="${i}"]`).innerHTML = filePreviewTemplate(file, image, i, 'done');
            })
            .catch(error => {
                previewsElement.querySelector(`[data-upload-preview="${i}"]`).innerHTML = filePreviewTemplate(file, image, i, 'error');
            });
    }
}
  • Create a FormData() object and append the image file to it.
  • Make a post request to /gallery-upload with the formData variable.
  • Use .then() to change the state of the image preview to done on success.
  • Use .catch() to change the state of the image preview to error on failure.

Because Axios uses JavaScript Promises to handle the request, all the images will upload asynchronously which means they won't block the for loop from continuing to the next image while the current one is uploading.


8. Watch For Input Changes

Let's set up a function to watch for changes to the files input. Every time it changes, we'll upload whatever images were selected.

function ajaxGalleryUploader() {
    let input = document.querySelector('#gallery-uploader');

    if (!input) {
        return;
    }

    input.addEventListener('change', () => {
        uploadImages(input.files);
    });
}

document.addEventListener('DOMContentLoaded', ajaxGalleryUploader);

In the above snippet we do a few things:

  • Find the input we'll be getting our images from.
  • Check if it exists. If not then we exit early.
  • Add an event listener for when the input changes and call our uploadImages function.

Conclusion

There you have it. A quick and simple multi-image uploader using Laravel, JavaScript and Tailwind.

As you can see from the above, the process of uploading images is actually very simple when using Laravel. The hardest part is writing the JavaScript to manage the previews and Ajax requests.

Laravel really shines in situations like this because you can so easily validate the incoming image, as well as store the image with a just a few lines of code.