Image Uploads Using Laravel 5.4 With Vue: Part 2
When we last left our image upload application in part 1 we had created a working image uploader, but it could still be improved. In this second part we are going to look at improving the application by adding in thumbnailing and moving our image listing into a separate page.
Vue Router
To start off we will install Vue Router. In your terminal, while in your Laravel project root, use NPM to install Vue Router:
1 |
npm install vue-router --save-dev |
We are using --save-dev
to make sure it is saved as a dependency in our project. Now let’s get our Router working.
To use Vue Router we need to re-organise our app.js
file a little while adding some new code. Let’s do that now.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
require('./bootstrap') import Vue from 'vue' import VueRouter from 'vue-router' Vue.use(VueRouter) Vue.component('image-list', require('./components/ImageList.vue')) Vue.component('upload', require('./components/Upload.vue')) const list = { template: '<image-list url="/list"></image-list>' } const up = { template: `<upload csrf="${window.Laravel.csrfToken}" action="/upload"></upload>` } const routes = [ { path: '/', component: up }, { path: '/list', component: list } ] const router = new VueRouter({ routes }) const app = new Vue({ router }).$mount('#app') |
There are a few changes here. We have imported VueRouter
, we have told Vue
that the router plugin exists using the Vue.use()
function.
Next we create two components that we can pass to our routes
object. We create our routes
object passing in a component to the relevant route. We then pass that routes
object to VueRouter
, note that just passing the routes
object is shorthand for routes: routes
. Finally we pass the router
object to Vue and mount the app again.
Don’t forget to recompile your Javascript by using.
1 |
npm run watch |
Next we need to adjust our blade template. I will show all the code inside the body
tag to help with brevity.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
<div id="app"> <nav class="navbar navbar-default"> <div class="container"> <div class="navbar-header"> <button type="button" class="collapsed navbar-toggle" data-toggle="collapse" data-target="#navbar-collapse" aria-expanded="false"> <span class="sr-only">Toggle navigation</span> <span class="icon-bar"></span> <span class="icon-bar"></span> <span class="icon-bar"></span> </button> </div> <div class="collapse navbar-collapse" id="navbar-collapse"> <ul class="nav navbar-nav"> <router-link to="/" tag="li" active-class="active" exact><a>Upload</a></router-link> <router-link to="/list" tag="li" active-class="active" exact><a>List</a></router-link> </ul> </div> </div> </nav> <div class="container"> <div class="content"> <router-view></router-view> </div> </div> </div> <script> window.Laravel = {!! json_encode([ 'csrfToken' => csrf_token(), ]) !!}; </script> <script src="/js/app.js"></script> |
Here we’ve moved our code into a container for the Vue
mount and added in a navigation bar. We are just using Bootstrap’s since we have it included anyway. You can see we also have a new tag router-link
this creates a hyperlink that goes to a route rather than a different web page. The extra parameters are as follows:
- to: which route path to link too
- tag: which html tag should be used when creating the link. We are using li here as that is the tag bootstrap applies its active class too
- active-class: what class should be used when the link is active
- exact: should the route have to be matched exactly to be classed as active
The a
tag inside is recognised by Vue and is used to create the real hyperlink.
The final part is the router-view
tag. This is where Vue Router will place the content of the current route. You can have more than one and they can be named, but we are keeping it simple here and just using a single default tag.
Changes To Image Listing
Now we are going to adjust our image listing to suit being on its own page and to be ready for adding in thumbnails.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 |
<template> <div class="container"> <div class="row"> <div class="title m-b-md"> Images </div> </div> <div class="row"> <div class="col-md-12" v-if="loading"> Loading... </div> <div class="col-md-12" v-if="files.length > 0"> <a v-for="file in files" :href="file.path" class="vue-image"> <img :src="file.thumb" class="img-rounded" width="200"> </a> </div> <div class="col-md-12" v-if="files.length == 0 && !loading"> <p> Oh Noes! There was no images available! </p> </div> </div> </div> </template> <script> import axios from 'axios' import bus from '../bus' export default { created() { this.refresh(); bus.$on('uploaded', (file) => { if(file.status == "success") this.refresh() }); }, watch: { '$route': 'refresh' }, props: { url: { type: String } }, data() { return { files: [], loading: false } }, methods: { refresh() { this.loading = true; axios.get(this.url).then( response => { this.loading = false; if(response.data) { this.files = response.data } } ) } } } </script> |
The HTML has changed a fair bit, we’ve basically added a heading and some conditionals. First is a loader, you can replace the text with a loading graphic such as one from loading.io. Next we have a check for files and a message to show if no images could be found.
The JavaScript code hasn’t changed too much. We have added watch
which allows us to run a certain method when the route is activated. In this case we just run the refresh method to make sure our images are up-to-date. A more robust system that checks to see if the images need to be refreshed might be called for here, but this is fine for the purposes of this tutorial. We also add the loading
variable to our data and do a simple true/false
switch during the refresh method to turn it on and off.
Laravel Models
.env
file and enter your MySQL connection details.Now we can move on to our Laravel code. First we are going to change to using a database as our source for the images as opposed to just listing them from the directory like in the first part of this tutorial. So let’s create our model. We are going to use Artisan so if you are using Docker or a VM you’ll need to login to that to use it.
1 |
php artisan make:model Image -m |
That will generate a model and a migration. First let’s open up the migration file it should be located in /database/migrations
. You will want to add a path
field and a thumb
field your Schema block might look like this.
1 2 3 4 5 6 |
Schema::create('images', function (Blueprint $table) { $table->increments('id'); $table->timestamps(); $table->string('path'); $table->string('thumb'); }); |
Now we can run our migration. Back with artisan.
1 |
php artisan migrate |
This will run the migration and create the table. Next we can open up the Model which should be located in the /app
folder. This file will be pretty simple.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
<?php namespace App; use Illuminate\Database\Eloquent\Model; class Image extends Model { protected $fillable = ['path', 'thumb']; public function getPathAttribute($value) { return asset('storage/' . $value); } public function getThumbAttribute($value) { return asset('storage/' . $value); } } |
We add in a $fillable
property to allow use to mass assign both path
and thumb
. We also define two accessors to adjust the path we have saved in the database. These adjust the path after retrieving them, the data in the table remains untouched. The saved path will be relative and the storage
folder will be missing from the path laravel returns due to it being a symbolically linked public folder. I’m unsure if this is expected behaviour of Laravel but it is something I came across consistently and so worked around it like this.
Main Controller Adjustments
Now we can adjust our main controller to thumbnail files on upload. First though we need something to do the thumbnailing as Laravel doesn’t do it out-of-the-box. With that in mind we are going to pull the excellent Intervention Image library to do the work for us. To install please follow the instructions on the Intervention website as it is written out on there perfectly. There is however one caveat. Since our model is named Image
you will want to rename the facade for Intervention to something else. I used InterImage
.
Once installed we can adjust our controller.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 |
<?php namespace App\Http\Controllers; use Illuminate\Support\Facades\Storage; use Illuminate\Http\Request; use Illuminate\Support\Str; use Illuminate\Http\File; use InterImage; use App\Image; class MainController extends Controller { public function home() { return view('welcome'); } public function upload(Request $request) { $this->validate($request, [ 'file' => 'required|image|max:2048' ]); $file = $request->file('file'); // Double check file validity if(!$file->isValid()) return response()->json([ 'error' => 'File is not valid!' ]);; $image = Storage::disk('public')->putFile('images', $file); $stream = InterImage::make(Storage::disk('public')->get($image))->fit(300, 300)->stream(); $thumb = 'images/' . Str::random(40) . '.jpeg'; Storage::disk('public')->put($thumb, $stream); $image = Image::create([ 'path' => $image, 'thumb' => $thumb, ]); if($image) return response()->json([ 'success' => 'File Uploaded' ]); return response()->json([ 'error' => 'Saving Failed' ]); } public function list() { return Image::all(); } } |
We’ve changed the code a bit here. First note all of the namespaces we have imported at the top. Again InterImage
is the name I used for the Intervention Image facade if you chose something different you’ll need to change that to whatever you chose in the code above.
First up we are adding some file validation using the validate
method that is automatically included by Laravel on any Controller that extends the base Controller. We still check the file is valid even after validation, just in case.
Now we store our image using the putFile
method of the Storage
facade. We are doing this so that we get a valid path back that we can re-use in the Storage
facade. We then make a thumbnail using Intervention by passing through the image we just stored, the output is stored in a variable as a stream. We then create thumbnail path, for simplicity we are storing them in the same folder with a random name. Then we use the Storage
facade’s put
method to output the thumbnail as a file.
Once all that is complete we add the image and thumbnail paths to the database. We finally return a success or failure message based on the result.
One final note is the output of the list
method has changed to Image::all()
as we are now returning the results from a Database. Returning the method like this automatically returns a JSON object thanks to Eloquent.
Homework
You thought you left homework at school? Think again, here are some things you could add if you are feeling up to it.
Image Cleanup
If there is a failure after the image and thumbnail have been saved they will be left in the storage folder but never used due to their entry being missing from the database. Try to adjust the code to clean up if there is a catchable error after the images have been saved/created.
Image Naming
The images are named using a random string, Laravel uses this method for its store
method if you save the file directly from the request’s file object. However maybe they could be named in a better way more suitable for your application.
Image List Refresh
While loading a JSON response is efficient maybe there is a way to determine if the image list needs to be refreshed when the route is changed?
That’s It
We have finished. It’s been a long journey but that is finally it. You should now have a working uploader. It is fairly simple and there are a lot of improvements that can be made, but hopefully this helps with the basics.
If you have any questions please let me know in the comments, I will try to answer as quickly as I can.