Laravel 02 May 2022 7 minute read
A step-by-step guide on creating a multi-image uploader using Laravel, JavaScript & Tailwind.
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.
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.
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>
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
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.
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 elementimage
- 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
.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.
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');
});
}
}
FormData()
object and append the image file to it./gallery-upload
with the formData
variable..then()
to change the state of the image preview to done
on success..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.
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:
uploadImages
function.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.