How To Use VueJS with Laravel Blade

Click to share! ⬇️

How To Use VueJS with Laravel Blade

This is a little bit of a challenging tutorial. What we want to do is implement inline-templates using blade into pure JavaScript or VueJS. Here is the challenge. As you know, blade has many convenience functions built in that allow you to quickly handle authorization, authentication, and helpful constructs like the @forelse loop. If we want an entirely Vue based solution, we’ll need to redo much of this functionality again on the client side. It’s not the most fun thing in the world, but if you want that entirely slick SPA like feel – it’s something we need to do. So with that, let’s see if we can figure out how to move blade functionality over into Vue components.


The Current Code

At the moment, in threads/show.blade.php, we loop over all of the replies and for each one a Reply.vue single file component is rendered. The goal is to move the replies into a collection of replies that can be accessed and modified in Vue itself.

Essentially, instead of this:

@foreach($replies as $reply)
    @include('threads.reply')
@endforeach  

We want to do this:

<replies :data="{{ $thread->replies }}"></replies>

Replies.vue

Ok so the first step to make this work is to create that new Replies.vue component in our project.
new wrapping vue component

Our first bit of code in the Replies.vue component will start with this.

<template>
    <div>
        <div v-for="reply in items">
            <reply :data="reply"></reply>
        </div>
    </div>
</template>

<script>
    import Reply from './Reply.vue';

    export default {
        props: ['data'], // accepts data for replies

        components: {Reply},

        data() {
            return {
                items: this.data
            }
        }
    }
</script>

Since we have used an import statement for the Reply component above, it no longer needs to be declared globally in app.js. So go ahead and remove the line:

Vue.component('reply', require('./components/Reply.vue'));

from the app.js file.

While we are at it, we’ll make use of Ctrl+Alt+Shift+J to select all occurrences of ‘attributes’ in Reply.vue and refactor to ‘data’ to keep things more consistent.
Ctrl+Alt+Shift+J phpstorm all occurrences


pages/Thread.vue

We also want control over other areas of the thread page. For example, the sidebar that displays how many replies there are needs to update in real time. We’ll add another page specific component for that with Thread.vue.
single use component vue

<script>
    export default {
        
    }
</script>

And we will register this in app.js like so.

require('./bootstrap');

window.Vue = require('vue');

Vue.component('flash', require('./components/Flash.vue'));

Vue.component('thread-view', require('./pages/Thread.vue'));

const app = new Vue({
    el: '#app'
});

Calling <thread-view> in threads/show.blade.php

With this new thread view component defined, we can reference it in threads/show.blade.php like so.

@extends('layouts.app')

@section('content')
    <thread-view 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">
                            <span class="flex">
                                <a href="{{ route('profile', $thread->creator) }}">{{ $thread->creator->name }}</a> posted:
                                {{ $thread->title }}
                            </span>
                                @can('update', $thread)
                                    <form action="{{$thread->path()}}" method="POST">
                                        {{ csrf_field() }}
                                        {{ method_field('DELETE') }}
                                        <button class="btn btn-link">Delete Thread</button>
                                    </form>
                                @endcan
                            </div>
                        </div>
                        <div class="panel-body">
                            {{ $thread->body }}
                        </div>
                    </div>

                    <replies :data="{{ $thread->replies }}"></replies>

                    {{--@foreach($replies as $reply)--}}
                    {{--@include('threads.reply')--}}
                    {{--@endforeach--}}

                    {{ $replies->links() }}

                    @if(auth()->check())
                        <form method="POST" action="{{$thread->path() . '/replies'}}">
                            {{csrf_field()}}
                            <div class="form-group">
                                <textarea name="body" id="body" class="form-control" placeholder="What do you think?" rows="5"></textarea>
                            </div>
                            <button type="submit" class="btn btn-default">Post</button>
                        </form>
                    @else
                        <p class="text-center">Please <a href="{{ route('login') }}">login</a> to respond.</p>
                    @endif
                </div>
                <div class="col-md-4">
                    <div class="panel panel-default">
                        <div class="panel-body">
                            This thread was created {{ $thread->created_at->diffForHumans() }} by
                            <a href="{{ route('profile', $thread->creator) }}">{{ $thread->creator->name }}</a> and currently
                            has {{ $thread->replies_count }} {{ str_plural('reply', $thread->replies_count) }}.
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </thread-view>
@endsection

Thread Must import Replies

In this snippet above, we are rendering replies using <replies :data=”{{ $thread->replies }}”></replies> so that must be explicitly defined in Thread.vue like so.

<script>
    import Replies from '../components/Replies.vue';

    export default {
        components: { Replies }
    }
</script>

Refactoring From Blade To Vue

With the work we have done so far, if we try to load up a threads page and look at Vue Developer tools, we do get an error. [Vue warn]: Failed to mount component: template or render function not defined. found in —> <Reply>
Vue warn Failed to mount component

This is because as of now, that is referencing the inline template defined in the reply.blade.php file. But wait. We are using Vue components all the way now, so what do we do? Well, this part is a little challenging, but it’s the only solution at this time. We need to basically reproduce everything blade does in the <template> section of the Reply.vue component. Here’s the rub. You lose all those nice little helper and convenience functions built into blade. In addition, all your variables need to be changed to work on the VueJS / JavaScript side. Fun. In any event, what happens is that the <template> portion of Reply.vue would translate roughly to something like this:

<template>
    <div :id="'reply-'+id" class="panel panel-default">
        <div class="panel-heading">
            <div class="level">
                <h5 class="flex">
                    <a :href="'/profiles/'+data.owner.name"
                       v-text="data.owner.name">
                    </a> said {{ data.created_at }}...
                </h5>

                <div v-if="signedIn">
                    <favorite :reply="data"></favorite>
                </div>
            </div>
        </div>

        <div class="panel-body">
            <div v-if="editing">
                <div class="form-group">
                    <textarea class="form-control" v-model="body"></textarea>
                </div>

                <button class="btn btn-xs btn-primary" @click="update">Update</button>
                <button class="btn btn-xs btn-link" @click="editing = false">Cancel</button>
            </div>

            <div v-else v-text="body"></div>
        </div>

        <div class="panel-footer level" v-if="canUpdate">
            <button class="btn btn-xs mr-1" @click="editing = true">Edit</button>
            <button class="btn btn-xs btn-danger mr-1" @click="destroy">Delete</button>
        </div>
    </div>
</template>

So what’s happening here? Well, the highlighted lines show where edits were needed to reproduce what we can do in blade, but now in JavaScript.


Removing jQuery and Using Vue

Prior to this tutorial, we were making use of a quick line of jQuery to fade out the reply element once it was deleted from the database. What is different now however is that each reply is part of a collection of replies in Vue. So we can just remove the element from the array in Vue, and the page will update automatically for us. No need to use jQuery in this case.


Children Fire Events, Parents Listen To Events

In order for a child component to communicate with a parent component, that child must emit an event. So what we are going to do is add the highlighted line below in Reply.vue to make this announcement that the element was deleted while simultaneously removing the call to jQuery.

<template>
    <div :id="'reply-'+id" class="panel panel-default">
        <div class="panel-heading">
            <div class="level">
                <h5 class="flex">
                    <a :href="'/profiles/'+data.owner.name"
                       v-text="data.owner.name">
                    </a> said {{ data.created_at }}...
                </h5>

                <div v-if="signedIn">
                    <favorite :reply="data"></favorite>
                </div>
            </div>
        </div>

        <div class="panel-body">
            <div v-if="editing">
                <div class="form-group">
                    <textarea class="form-control" v-model="body"></textarea>
                </div>

                <button class="btn btn-xs btn-primary" @click="update">Update</button>
                <button class="btn btn-xs btn-link" @click="editing = false">Cancel</button>
            </div>

            <div v-else v-text="body"></div>
        </div>

        <div class="panel-footer level" v-if="canUpdate">
            <button class="btn btn-xs mr-1" @click="editing = true">Edit</button>
            <button class="btn btn-xs btn-danger mr-1" @click="destroy">Delete</button>
        </div>
    </div>
</template>

<script>
    import Favorite from './Favorite.vue';

    export default {
        props: ['data'],

        components: {Favorite},

        data() {
            return {
                editing: false,
                id: this.data.id,
                body: this.data.body
            };
        },

        methods: {
            update() {
                axios.patch('/replies/' + this.data.id, {
                    body: this.body
                });
                this.editing = false;
                flash('Updated!');
            },

            destroy() {
                axios.delete('/replies/' + this.data.id);

                this.$emit('deleted', this.data.id);
    
                // $(this.$el).fadeOut(1000, () => {
                //     flash('Your reply is now deleted!');
                // });
            }
        }
    };
</script>

Who is the parent of Reply? Well that would be Replies.vue of course. So since we are setting up the firing of an event on the child, we must now set up a listener on the parent. So in the <template> area of the parent Replies, we will modify the code like so:

<template>
    <div>
        <div v-for="(reply, index) in items">
            <reply :data="reply" @deleted="remove(index)"></reply>
        </div>
    </div>
</template>

<script>
    import Reply from './Reply.vue';

    export default {
        props: ['data'], // accepts data for replies

        components: {Reply},

        data() {
            return {
                items: this.data
            }
        },

        methods: {
            remove(index) {
                this.items.splice(index, 1)
            }
        }
    }
</script>

What happens now is that the parent picks up on that ‘deleted’ event and we can trigger a remove() method that we also defined in the methods area of the Vue instance. In that method, we just use the JavaScript Splice Function to remove the exact element we want gone. Once it is removed, Vue automatically re renders the page, and Poof! The element is gone.


Updating The Reply Count In Real Time

Another feature we can now add is the ability to update the reply count on the side bar instantly with no need for a page refresh. We’ll set up Thread.vue like so:

<script>
    import Replies from '../components/Replies.vue';

    export default {
        props: ['initialRepliesCount'],

        components: {Replies},

        data() {
            return {
                repliesCount: this.initialRepliesCount
            };
        }
    }
</script>

Then bind repliesCount in threads/show.blade.php with something like <span v-text=”repliesCount”></span> Once again, we remove the blade syntax of {{ $thread->replies_count }} and replace it with Vue. You would also need to update the opening <thread-view> tag like so <thread-view :initial-replies-count=”{{ $thread->replies_count }}” inline-template> in threads/show.blade.php.


Replies fires an event, Thread listens for an event

The same thing is now occurring where we need to notify the parent that something has changed. We’ll need to update Replies.vue to emit an event anytime a reply is added or removed. We’ll also add in the add() method which makes use of the JavaScript Push Function to add an item to the collection.

<template>
    <div>
        <div v-for="(reply, index) in items">
            <reply :data="reply" @deleted="remove(index)"></reply>
        </div>
    </div>
</template>

<script>
    import Reply from './Reply.vue';

    export default {
        props: ['data'], // accepts data for replies

        components: {Reply},

        data() {
            return {
                items: this.data
            }
        },

        methods: {
            add(reply) {
                this.items.push(reply);

                this.$emit('added');
            },

            remove(index) {
                this.items.splice(index, 1);

                this.$emit('removed');

                flash('Reply was deleted!');
            }
        }
    }
</script>

This means that we now have a custom event of ‘added’ being emitted when a reply is added, and a custom event of ‘removed’ when a reply is deleted. Therefore, in threads/show.blade.php we can update the tag to the following. It reads, when ‘added’ is triggered, increment the replies count. When ‘removed’ is triggered, decrement the replies count.

<replies :data="{{ $thread->replies }}" @added="repliesCount++" @removed="repliesCount--"></replies>

Handling Blade Authorization In Vue Components

Here is yet another tricky thing we must tackle when trying to extract components with blade functionality. In blade, we were using authorization to selectively display the Favorite button like so.

@if (Auth::check())
    <div>
        <favorite :reply="{{ $reply }}"></favorite>
    </div>
@endif

This can not be used in Vue, so we need an alternate solution. What do we do? Well ultimately in Vue, it is going to look a bit like this:

<div v-if="signedIn">
    <favorite :reply="data"></favorite>
</div>

We’ll need to make use of a new JavaScript variable, and use the v-if directive to decide whether to show the favorite button or not. We can set up this variable in addition to a user variable in the app.blade.php layout file like so.

<script>
    window.App = {!! json_encode([
        'user' => Auth::user(),
        'signedIn' => Auth::check()
    ]) !!};
</script>

With the global set above, we can use a computed property in Reply.vue like this:

computed: {
    signedIn() {
        return window.App.signedIn;
    },

With that in place, the authorization for the favorite button should now work.

The other blade markup we have to re create in JavaScript is this.

@can('update', $thread)
    <form action="{{$thread->path()}}" method="POST">
        {{ csrf_field() }}
        {{ method_field('DELETE') }}
        <button class="btn btn-link">Delete Thread</button>
    </form>
@endcan

In Vue, we are going to translate that to this.

<div class="panel-footer level" v-if="canUpdate">
    <button class="btn btn-xs mr-1" @click="editing = true">Edit</button>
    <button class="btn btn-xs btn-danger mr-1" @click="destroy">Delete</button>
</div>

To make this work, we’ll need to define a computed property like so in Reply.vue.

canUpdate() {
    return this.authorize(user => this.data.user_id == user.id);
}

as well as add an authorize() function in bootstrap.js like this.

window.Vue.prototype.authorize = function (handler) {
    let user = window.App.user;

    return user ? handler(user) : false;
};

With these updates, authorization will now work properly on the client side, even without the familiar blade shortcuts we are used to. We’ll take just a quick look at how things are working now. Notice when the user deletes a reply, it is removed from the list of replies instantly. There was no page refresh. In addition, the reply count also updated instantaneously thanks to the communication happenning between Vue components. It’s a lot to keep track of when you’re building it, but the end result is very nice.
vue component instant feedback


A Visual Guide To Your Components

Finally, we can just take a quick look in Vue Developer tools and see how these various components are working together. We now have a parent <ThreadView> component, with <Replies> existing inside of that. The <Replies> component, is a a collection of <Reply> components. This is what makes it so easy to update the number of actual <Reply> elements on the page now. We also see that each <Reply> has a <Favorite> component as well as the <Flash> component we had created earlier. Clicking on each component shows you the reactive data associated with it in the right pane of the developer tools. Pretty slick!
vue dev tools child components


How to use VueJS with Laravel Blade Summary

Recreating all of what blade view files offer into VueJS components is somewhat challenging. There are definitely tradeoffs for the amount of refactoring it takes to get a fully component based solution working well. Yes, the slick single page application like feel is very nice, but it takes time to re do the things you are used to completing very quickly in blade such as handling user authentication and authorization if else type UI display issues. Over time, this will likely get much easier as some best practices will evolve that will make components second nature. Until then, be ready to spend a lot of time refactoring your blade views to a collection of Vue components that talk to each other!

Click to share! ⬇️