Uppy File Uploader in Laravel & Vue.js Apps

Handling single file uploads with Uppy in a Laravel & Vue.js app

Photo by Mathew Schwartz on Unsplash

Disclaimer:

This content is developed to give junior-intermediate Laravel/Vue.js developers (or enthusiasts) a general sense of how to implement Uppy file uploader JS plugin within a Laravel & Vue.js based application. My featured implementation isn’t necessarily ‘perfect/superior/acclaimed’. My intention is to help you find a path to your own ‘perfect’ implementation — one that’ll hopefully suit your use case better.

Before we begin, I’d like to assume a few things — not covered herein:

Some technical details about my local setup — just for reference

Versions:

Back story

Uppy JS file uploader offers a ton of functionality out-of-the-box, to facilitate seamless UX across a multiplicity of file selection & upload processes in web applications, and I have found it exceptionally useful in my own projects.

I noticed there almost isn’t any resource online offering guides on usage in Laravel and/or Vue.js (based apps) specific use case(s), and decided to fix that anomaly.

Setting up

To begin our implementation, let’s pull in a few dependencies:

npm install @uppy/core @uppy/xhr-upload @uppy/dashboard @uppy/form

Now, we can go ahead and set up the clientside of our feature.

Step 1: within our resources directory, let’s build up a uppy_uploader.js script with a new Vue instance.

We’ll use the el API to specify an option for an existing DOM element to mount this instance on. In this case: uppy-uploader

import Vue from 'vue';
import UppyUploader from '../components/UppyUploader';

new Vue({
el: 'uppy-uploader',
components:{
UppyUploader
}
});

Step 2: Let’s create the component UppyUploader we referenced above:

<template>
<form>
<div class="image-container mb-3" v-if="previewPath">
<img :src="previewPath" alt="Uploaded Image Preview">
</div>
<div class="form-group">
<div ref="dashboardContainer"></div>
</div>
<button :disabled="disabled" @click.prevent="confirmUpload" class="btn btn-primary btn-block mb-2">Confirm upload</button>
</form>
</template>

<script>
import Uppy from '@uppy/core';
import XHRUpload from '@uppy/xhr-upload';
import Dashboard from '@uppy/dashboard';
import Form from '@uppy/form';

import notify from './mixins/noty';

import '@uppy/core/dist/style.css';
import '@uppy/dashboard/dist/style.css';

export default {
props: {
maxFileSizeInBytes: {
type: Number,
required: true
}
},
mixins: [notify],
data() {
return {
payload: null,
previewPath: null,
disabled: true
}
},
mounted() {
this.instantiateUppy()
},
methods: {
instantiateUppy() {
this.uppy = Uppy({
debug: true,
autoProceed: true,
restrictions: {
maxFileSize: this.maxFileSizeInBytes,
minNumberOfFiles: 1,
maxNumberOfFiles: 1,
allowedFileTypes: ['image/*', 'video/*', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/pdf']
}
})
.use(Dashboard, {
hideUploadButton: true,
inline: true,
height: 450,
target: this.$refs.dashboardContainer,
replaceTargetContent: true,
showProgressDetails: true,
browserBackButtonClose: true

})
.use(XHRUpload, {
limit: 10,
endpoint: '/file/upload',
formData: true,
fieldName: 'file',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content') // from <meta name="csrf-token" content="{{ csrf_token() }}">
}
});

this.uppy.on('complete', (event) => {
if(event.successful[0] !== undefined) {
this.payload = event.successful[0].response.body.path;

this.disabled = false;
}
});
},
updatePreviewPath({path}) {
this.previewPath = path;

return this;
},
resetUploader() {
this.uppy.reset();
this.disabled = true;

return this;
},
confirmUpload() {
if(this.payload) {
this.disabled = true;
axios.post('/store', { file: this.payload })
.then(({ data }) => {
this.updatePreviewPath(data)
.resetUploader()
.notify('success', 'Upload Successful!');
})
.catch(err => {
console.error(err);

this.resetUploader();
})
;
} else notify('warning', `You don't have any file in processing`);

}
}
};
</script>

<style scoped>
.image-container {
height: 150px;
width: 150px;
border-radius: 50%;
overflow: hidden;
margin-right: auto;
margin-left: auto;
}

.image-container > img {
width: inherit;
height: inherit;
}
</style>

Step 3: compile the script (associated modules) down with Laravel Mix, into the public directory.

It’ll be a path within our app’s public directory — Like so:

.js('resources/js/plugins/uppy_uploader.js', 'public/js/plugins')

With the necessary modifications, our webpack.mix.js should end up looking close to the following:

(Don’t forget to comment out whatever is already compiled and (or) isn’t expected to be modified at this time)

mix.js('resources/js/app.js', 'public/js')
.js('resources/js/plugins/user_profiles.js', 'public/js/plugins')
// .sass('resources/sass/app.scss', 'public/css');

At this point, we can start to actively watch for changes to any parts of our module by running:

// auto recompile when any changes are madenpm run watch

Step 4: Let’s create a blade template to house our earlier referenced UppyUploader Vue component:

<uppy-uploader
:max-file-size-in-bytes="1000000">
</uppy-uploader>

We need a blade template to render the component above

@extends('layouts.app')

@section(
'content')
<div class="container">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card">
<div class="card-body">
<uppy-uploader
:max-file-size-in-bytes="1000000">
</uppy-uploader>

</div>
</div>
</div>
</div>
</div>
@endsection

@section(
'scripts')
<script src="/js/plugins/uppy_uploader.js"></script>
@endsection

Side note

Notice how we didn’t need to import axios in the above component’s script? Well, that’s because we already did that globally in our precompiled bootstrap.js file, like so:

/**
* We'll load the axios HTTP library which allows us to easily issue requests
* to our Laravel backend. This library automatically handles sending the
* CSRF token as a header based on the value of the "XSRF" token cookie.
*/
window.axios = require('axios');

Also, notice how I have my notification module plugged in via mixins? That’s set up to avoid repetition with codes like this, that are likely to be used within various parts of my frontend components.

Don’t forget to install Noty before importing it into your JS module

via NPM:

npm install noty

and add styling files to resources/assets/sass/app.scss

// Fonts
@import url('https://fonts.googleapis.com/css?family=Nunito');
// Variables
@import 'variables';
// Bootstrap
@import '~bootstrap/scss/bootstrap';
// Noty
@import "~noty/src/noty.scss";
@import "~noty/src/themes/mint.scss";

There’s just a single method in here, for now, so I just did a default export:

import Noty from 'noty';export default {
methods: {
notify(type, text){
new Noty({
text,
type,
layout: 'top',
theme: 'mint',
timeout: 3000,
}).show();
}
}
};

and then imported with:

import notify from './mixins/noty';

If you’re not already familiar with Vue mixins, you may have to research on that bit for clarity.

Part 2: Backend bits

Part 1: Database Model, Migration, and Controller

I’ll call the model Post, and generate that, a migration file, and a resourceful controller to go with, with this line of code:

php artisan make:model Post -mr

When that’s done, I’ll set up my database table with a simple ‘file_path’ column, just for this example.

<?php

use
Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreatePostsTable extends Migration
{
/**
* Run the migrations.
*
*
@return void
*/
public function up()
{
Schema::create('posts', function (Blueprint $table) {
$table->bigIncrements('id');
$table->string('file_path');
$table->timestamps();
});
}

/**
* Reverse the migrations.
*
*
@return void
*/
public function down()
{
Schema::dropIfExists('posts');
}
}

Then let’s set up our Post model like so —

<?php

namespace
App;

use Illuminate\Database\Eloquent\Model;

class Post extends Model
{
protected $fillable = ['file_path'];

public static function publishNewPost($post)
{
return static::create([
'file_path' => $post
]);
}
}

Of the predefined methods within our Resource PostController, we’re only interested in the store method to facilitate our implementation:

<?php

namespace
App\Http\Controllers;

use App\Post;
use App\Traits\UppyUploaderTrait;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;

class PostController extends Controller
{
use UppyUploaderTrait;

public function store(Request $request)
{
if ($request->file) {
$post = Post::publishNewPost($this->getMovedFilePath($request->file));

return response([
'path' => Storage::url($post->file_path)
], 201);
}

return response([], 400);
}
}

Part 2: Request routes

Within our web.php file, let’s define the necessary routes:

// file uploader routes
Route::view('/upload', 'upload'); // returns upload page
// auto upload/store file to temporary storage path
Route::post('/file/upload', 'PostController@upload');
// store form information and move file to permanent storage path
Route::post('/store', 'PostController@store');

Part 3: Let’s create an uploader trait — UppyUploaderTrait (in App\Traits)

How to stay DRY (Don’t Repeat Yourself)?

Since we’ll likely be utilizing our uploader logic in more than one part of our backend (eg. within multiple controllers), a trait like this one will help us keep our logic portable and avoid code duplications. We’ll simply use the trait anywhere we need it:

<?php

namespace
App\Http\Controllers;

use App\Post;
use App\Traits\UppyUploaderTrait;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;

class PostController extends Controller
{
use UppyUploaderTrait; // import uploader trait

...
}

Our trait will contain the following logic:

<?php

namespace
App\Traits;

use App\Services\FileUploadProcessor;
use Illuminate\Http\Request;

trait UppyUploaderTrait
{
protected $fileUploadProcessor;

public function __construct(FileUploadProcessor $fileUploadProcessor)
{
$this->fileUploadProcessor = $fileUploadProcessor;
}

public function upload(Request $request)
{
return response([
'path' => $this->fileUploadProcessor->generateTempFileStoragePath($request)
], 200);
}

public function getMovedFilePath($file)
{
return $this->fileUploadProcessor->moveFileToRealStoragePath($file);
}
}

FileUploadProcessor should get resolved automatically using Laravel’s Reflection API, and an instance injected into our trait through the constructor.

...protected $fileUploadProcessor;

public function __construct(FileUploadProcessor $fileUploadProcessor)
{
$this->fileUploadProcessor = $fileUploadProcessor;
}
...

Now, let’s define the class —

Part 4: Encapsulate uploader business logic in a service class to keep our controller(s) slim and tidy — FileUploadProcessor (in App\Services)

<?php

namespace
App\Services;

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

/**
* Class FileUploadProcessor
*
@package App\Services
*/
class FileUploadProcessor
{
/**
*
@param $filePath
*
@return mixed
*/
protected function getFileExtension($filePath)
{
return pathinfo($filePath, PATHINFO_EXTENSION);
}

/**
*
@param $extension
*
@return string
*/
protected function generateFileName($extension)
{
return Str::random(40) . '.' . $extension;
}

/**
*
@param $filePath
*
@return string
*/
public function generateRealFileStoragePath($filePath)
{
$extension = $this->getFileExtension($filePath);
$newFilename = $this->generateFileName($extension);

return 'uploads/' . $newFilename;
}

/**
*
@param Request $request
*
@return false|string
*/
public function generateTempFileStoragePath(Request $request)
{
return $request->file('file')->store('/uploads/temp', 'public');
}

/**
*
@param $tempPath
*
@return string
*/
public function moveFileToRealStoragePath($tempPath)
{
$newPath = $this->generateRealFileStoragePath($tempPath);

Storage::disk('public')->move($tempPath, $newPath);

return $newPath;
}
}

If you get any errors, check to ensure the class has been autoloaded, if not, you may have to run:

composer dump-autoload

And if all goes to plan, uploading files should now be a breeze!

Associated Github commit:

I’ll push the codes in this lesson to a branch on my public Github repo where you can go over my them for reference:

https://github.com/davealex/experiments/tree/ft-uppy-file-uploader

I’m David — A Nigerian software engineer, coffee drinker, and innovator. Thanks for tuning in!

Human & software engineer. Interested in ()=> {[Arts, Education, Music, Science, Tech, AI, co-Humans]};