In this tutorial we’ll create a VueJS image upload component so that users can upload an image to use as an avatar in the application. The goal is to re-create the mechanism of an image upload via an HTML form, however, it will all be done in a Vue component. This will allow for real-time image updating in the browser, in addition to putting the image in storage and setting the avatar image path in the database in one fell swoop. There is a lot to cover, so let’s jump right into uploading an image to the server using VueJS.
Getting The Database Ready
When uploading an image, you typically store the image on some type of storage medium such as the local filesystems, Amazon S3, or maybe even Rackspace Cloud Storage. With regard to the database, it is the path to the image that you usually store there. In other words, a string representation of how to reach the image. For our purposes, we’ll add a column to the users table named avatar_path. To do this, we’ll create and run this migration.
vagrant@homestead:~/Code/forumio$ php artisan make:migration add_avatar_path_to_users_table --table=users
<?php
use IlluminateSupportFacadesSchema;
use IlluminateDatabaseSchemaBlueprint;
use IlluminateDatabaseMigrationsMigration;
class AddAvatarPathToUsersTable extends Migration
{
public function up()
{
Schema::table('users', function (Blueprint $table) {
$table->string('avatar_path')->nullable();
});
}
public function down()
{
Schema::table('users', function (Blueprint $table) {
//
});
}
}
vagrant@homestead:~/Code/forumio$ php artisan migrate Migrating: 2018_02_27_193627_add_avatar_path_to_users_table Migrated: 2018_02_27_193627_add_avatar_path_to_users_table
Migrations are flexible in this way. We are able to set up a migration file to simply add a column without the need to rollback existing schema and data in the database. If we look at the users table, here we see what we need. A new column where we can store the image file path which begins with a null value.
A User Can Upload An Avatar Test
Now what we’ll do is create a test class of AddAvatarTest and the first test method will be test_a_user_may_add_an_avatar_to_their_profile().
- Given we have a signed in user
- Given we have a disk to store to (faked)
- When we submit a json post request to the api
- Then the file path should match the users avatar_path
- Then the disk should contain that file path
public function test_a_user_may_add_an_avatar_to_their_profile()
{
$this->signIn();
Storage::fake('public');
$this->json('POST', 'api/users/' . auth()->id() . '/avatar', [
'avatar' => $file = UploadedFile::fake()->image('avatar.jpg')
]);
$this->assertEquals('avatars/' . $file->hashName(), auth()->user()->avatar_path);
Storage::disk('public')->assertExists('avatars/' . $file->hashName());
}
Now we can start hammering out the test to see what we need to do in order for it to pass.
vagrant@homestead:~/Code/forumio$ phpunit --filter test_a_user_may_add_an_avatar_to_their_profile PHPUnit 6.5.5 by Sebastian Bergmann and contributors. E 1 / 1 (100%) Time: 1.36 seconds, Memory: 10.00MB There was 1 error: 1) TestsFeatureAddAvatarTest::test_a_user_may_add_an_avatar_to_their_profile SymfonyComponentHttpKernelExceptionNotFoundHttpException: POST http://localhost/api/users/1/avatar
The NotFoundHttpException means we are missing a route. Ok, we can add a new route to support the file upload to our web.php routes file. Add this to the end of that file. It is a post request to the api namespace which has a user wildcard referencing a UserAvatarController class with a method of store() which has an auth middleware and a named route of ‘avatar’. LOL! Sorry, run on sentence.
Route::post('api/users/{user}/avatar', 'ApiUserAvatarController@store')->middleware('auth')->name('avatar');
The first error is fixed, let’s run the test to see what is next.
vagrant@homestead:~/Code/forumio$ phpunit --filter test_a_user_may_add_an_avatar_to_their_profile PHPUnit 6.5.5 by Sebastian Bergmann and contributors. E 1 / 1 (100%) Time: 1.23 seconds, Memory: 10.00MB There was 1 error: 1) TestsFeatureAddAvatarTest::test_a_user_may_add_an_avatar_to_their_profile ReflectionException: Class AppHttpControllersApiUserAvatarController does not exist
That sounds about right. We have a ReflectionException which states the UserAvatarController class does not exist. Let’s create it!
vagrant@homestead:~/Code/forumio$ php artisan make:controller Api/UserAvatarController Controller created successfully.
That should fix error number 2, let’s see what is next.
vagrant@homestead:~/Code/forumio$ phpunit --filter test_a_user_may_add_an_avatar_to_their_profile PHPUnit 6.5.5 by Sebastian Bergmann and contributors. E 1 / 1 (100%) Time: 894 ms, Memory: 10.00MB There was 1 error: 1) TestsFeatureAddAvatarTest::test_a_user_may_add_an_avatar_to_their_profile BadMethodCallException: Method [store] does not exist on [AppHttpControllersApiUserAvatarController].
Now we see that we are missing the store() method in that controller. We are letting the test results guide us on each next step. We can add that method now. The store method is going to need to actually perform some work as well. So let’s think about what we want it to do. Well first off, we should validate that the user has provided an image in the request. Next, we are going to make use of a policy which we will create in a moment to determine if the user has the permissions to upload this image. Last, we’ll set the avatar_path to be stored in the avatars public directory. So let’s add this code like so.
<?php
namespace AppHttpControllersApi;
use AppHttpControllersController;
class UserAvatarController extends Controller
{
public function store()
{
$this->validate(request(), [
'avatar' => ['required', 'image']
]);
auth()->user()->update([
'avatar_path' => 'storage/' . request()->file('avatar')->store('avatars', 'public')
]);
return response([], 204);
}
}
Let’s run the test and see what happens.
vagrant@homestead:~/Code/forumio$ phpunit --filter test_a_user_may_add_an_avatar_to_their_profile PHPUnit 6.5.5 by Sebastian Bergmann and contributors. F 1 / 1 (100%) Time: 1.44 seconds, Memory: 10.00MB There was 1 failure: 1) TestsFeatureAddAvatarTest::test_a_user_may_add_an_avatar_to_their_profile Failed asserting that null matches expected 'http://localhost/avatars/ntyWkGe7Xvph8dxNZXPQe8HgEELzgZHW3G4B5BFe.jpeg'. /home/vagrant/Code/forumio/tests/Feature/AddAvatarTest.php:41 FAILURES! Tests: 1, Assertions: 1, Failures: 1.
Well this is interesting. It appears that the file itself is in fact being stored, but the path on the user object is null. Ok, we probably need to allow that column to be filled. Mass exception handling is likely protecting that right now. So in the User.php model we can add.
<?php
namespace App;
use CarbonCarbon;
use IlluminateNotificationsNotifiable;
use IlluminateFoundationAuthUser as Authenticatable;
class User extends Authenticatable
{
use Notifiable;
protected $fillable = [
'name', 'email', 'password', 'avatar_path'
];
Ok, we’re looking good now!
Create A User Policy
We’ll make use of a policy object to set permissions for users to update their avatar. Of course we don’t want someone to be able to update a different user’s avatar image, so having a User policy will make sure this base is covered.
vagrant@homestead:~/Code/forumio$ php artisan make:policy UserPolicy Policy created successfully.
The rule here is simply that the id of the signed in user matches that of the user being updated. If they match, then it’s the same person and we are good to go.
<?php
namespace AppPolicies;
use AppUser;
use IlluminateAuthAccessHandlesAuthorization;
class UserPolicy
{
use HandlesAuthorization;
public function update(User $signedInUser, User $user)
{
return $signedInUser->id === $user->id;
}
}
Anytime you make a new policy, you’ll need to register it in AuthServiceProvider for it to actually take effect.
<?php
namespace AppProviders;
use IlluminateSupportFacadesGate;
use IlluminateFoundationSupportProvidersAuthServiceProvider as ServiceProvider;
class AuthServiceProvider extends ServiceProvider
{
protected $policies = [
//'AppModel' => 'AppPoliciesModelPolicy',
'AppThread' => 'AppPoliciesThreadPolicy',
'AppReply' => 'AppPoliciesReplyPolicy',
'AppUser' => 'AppPoliciesUserPolicy',
];
public function boot()
{
$this->registerPolicies();
}
}
Now with a User policy, we’ll be able to selectively show an upload area only for the users that have permissions to see it. Now, before we get to far ahead, let’s add just a couple of more tests to the AddAvatarTest class. This will ensure that only members can upload an avatar image, and that a valid image must be provided.
public function test_only_members_can_add_avatars()
{
$this->withExceptionHandling();
$this->json('POST', 'api/users/1/avatar')
->assertStatus(401);
}
public function test_a_valid_avatar_must_be_provided()
{
$this->withExceptionHandling()->signIn();
$this->json('POST', 'api/users/' . auth()->id() . '/avatar', [
'avatar' => 'not-an-image'
])->assertStatus(422);
}
This test suite is passing, so we’re good.
Linking Storage Directory To Public Directory
Before we start building out the front end, we know that we are going to need to have access to the images in the public directory. But wait, Laravel stores images in the storage directory. So what do we do? We set up a symlink!
Here is what it does:
vagrant@homestead:~/Code/forumio$ php artisan help storage:link Usage: storage:link Help: Create a symbolic link from "public/storage" to "storage/app/public"
Now we can run it.
vagrant@homestead:~/Code/forumio$ php artisan storage:link In Filesystem.php line 228: symlink(): Operation not supported
Whoops! No worries, this is due to Windows being the host OS in my case. Just like in this form submission tutorial, we’ll have to manually make the symlink on the host OS.
C:>mklink /D C:localdevforumiopublicstorage C:localdevforumiostorageapppublic symbolic link created for C:localdevforumiopublicstorage <<===>> C:localdevforumiostorageapppublic
Setting A Default Avatar
When a user first signs up for an account, they might not feel like uploading an image avatar. In that case, we should provide a default image so that the layout looks consistent. We can add this function to the User.php model. It gets the users image avatar, and if that is not present it will use the default.
public function getAvatarPathAttribute($avatar)
{
return asset($avatar ?: 'images/avatars/default.png');
}
The VueJs Image Uploader
Now we can start tackling the front end side and build out the component to upload an image as an avatar. Actually, we’ll do this in two steps. First we’ll create the ImageUpload.vue component.
The component will have the following markup.
<template>
<input type="file" accept="image/*" @change="onChange">
</template>
<script>
export default {
methods: {
onChange(e) {
if (! e.target.files.length) return;
let file = e.target.files[0];
let reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = e => {
let src = e.target.result;
this.$emit('loaded', { src, file });
};
}
}
}
</script>
The second portion of the Vue image uploader will be the AvatarForm.vue component.
<template>
<div>
<div class="level">
<img :src="avatar" width="50" height="50" class="mr-1">
<h1 v-text="user.name"></h1>
</div>
<form v-if="canUpdate" method="POST" enctype="multipart/form-data">
<image-upload name="avatar" class="mr-1" @loaded="onLoad"></image-upload>
</form>
</div>
</template>
<script>
import ImageUpload from './ImageUpload.vue';
export default {
props: ['user'],
components: {ImageUpload},
data() {
return {
avatar: this.user.avatar_path
};
},
computed: {
canUpdate() {
return this.authorize(user => user.id === this.user.id);
}
},
methods: {
onLoad(avatar) {
this.avatar = avatar.src;
this.persist(avatar.file);
},
persist(avatar) {
let data = new FormData();
data.append('avatar', avatar);
axios.post(`/api/users/${this.user.name}/avatar`, data)
.then(() => flash('Avatar uploaded!'));
}
}
}
</script>
Register The Vue Component
We can register the AvatarForm.vue component using the highlighted line below in app.js.
require('./bootstrap');
window.Vue = require('vue');
Vue.component('flash', require('./components/Flash.vue'));
Vue.component('paginator', require('./components/Paginator.vue'));
Vue.component('user-notifications', require('./components/UserNotifications.vue'));
Vue.component('avatar-form', require('./components/AvatarForm.vue'));
Vue.component('thread-view', require('./pages/Thread.vue'));
const app = new Vue({
el: '#app'
});
Set A Default Image
In order to reference a default image, we need to store one on the server. We can place a default.png file in public/images/avatars like this.
Displaying An Avatar
In the threads/show.blade.php file we can now reference the avatar as seen here.
@extends('layouts.app')
@section('head')
<link rel="stylesheet" href="/css/vendor/jquery.atwho.css">
@endsection
@section('content')
<thread-view :initial-replies-count="{{ $thread->replies_count }}" inline-template>
<div class="container">
<div class="row">
<div class="col-md-8">
<div class="panel panel-default">
<div class="panel-heading">
<div class="level">
<img src="{{ $thread->creator->avatar_path }}"
alt="{{ $thread->creator->name }}"
width="25"
height="25"
class="mr-1">
<span class="flex">
<a href="{{ route('profile', $thread->creator) }}">{{ $thread->creator->name }}</a> posted:
{{ $thread->title }}
</span>
Looking pretty good!
Adding The Image Uploader To Profiles Page
Finally, we’ll go into the profiles/show.blade.php view file and add in the <avatar-form> Vue component. This should allow us to easily select an image and have it persist to the server while updating the avatar image instantly in the user interface.
@extends('layouts.app')
@section('content')
<div class="container">
<div class="row">
<div class="col-md-8 col-md-offset-2">
<div class="page-header">
<avatar-form :user="{{ $profileUser }}"></avatar-form>
</div>
@forelse($activities as $date => $activity)
<h3 class="page-header">{{ $date }}</h3>
@foreach($activity as $record)
@if (view()->exists("profiles.activities.{$record->type}"))
@include("profiles.activities.{$record->type}", ['activity' => $record])
@endif
@endforeach
@empty
<p>No activity for this user</p>
@endforelse
</div>
</div>
</div>
@endsection
We now log in as Tom, and visit his profile page. We can click the choose file button, and watch the image avatar update in real time. The cool thing is that not only did it update in real time in the browser, the image was also stored in storage in addition to the file path getting stored in the database. This means when the page is reloaded, the image will persist.
How The VueJs Image Upload Component Works
We didn’t talk a lot about how the code works inside the Vue Components so let’s review how this works.
The user chooses an image which causes the change
event to fire. This results in the onChange
function getting called.
<input type="file" accept="image/*" @change="onChange">
Inside the onChange
method, we first check to see if a file has been provided. If not, we just return.
if (!e.target.files.length) return;
If a file was selected, we will place it into the file
variable
let file = e.target.files[0];
Then the Javascript File Reader Object is used to read the contents of the file provided. Specifically, the readAsDataURL() method is used to determine the image representation of the file in a base64 encoded string. This is then set as the image source, which is what allows the image to update instantly in the browser.
let reader = new FileReader();
reader.readAsDataURL(file);
At this point a loaded
event is fired which AvatarForm.vue will listen for.
this.$emit('loaded', {src, file});
Now, in AvatarForm.vue, the loaded
event is picked up on and fires the onLoad
method.
<image-upload name="avatar" class="mr-1" @loaded="onLoad"></image-upload>
The onLoad
method sets the avatar source for real time image updating, then stores the file path in the database.
onLoad(avatar) {
this.avatar = avatar.src;
this.persist(avatar.file);
}
The persist
method makes use of the Javascript FormData interface to create a set of key/value pairs representing the form fields and their values, which are then sent as a post request to the server endpoint we had setup using axios.
persist(avatar) {
let data = new FormData();
data.append('avatar', avatar);
axios.post(`/api/users/${this.user.name}/avatar`, data)
.then(() => flash('Avatar uploaded!'));
}
VueJS Image Upload Summary
That pretty much sums up how the lifecycle of an avatar image upload works in VueJs. This tutorial had us creating the back end api in Laravel to support storing an image on the server, as well as using VueJs in concert with the Javascript FileReader and FormData objects to make a slick image upload component which renders in real time while also persisting to the database.