Click to share! ⬇️

VueJS Image Upload

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.
add_avatar_path_to_users_table


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!
test_a_user_may_add_an_avatar_to_their_profile is now passing


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.
AddAvatarTest suite passing


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.
vuejs ImageUpload 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.
vuejs avatar form 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.
default avatar image


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!
avatar now shows in the view file


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.
vue js image upload example


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.

Click to share! ⬇️