In previous chapters of this series, we considered a couple of concepts useful for developing modern web applications with Laravel and Vue.
In this final part, we are going to combine all the concepts from the previous parts into building a Trello clone using Laravel and Vue.
Here is a screen recording of what the application will look like when complete:
Prerequisites
To follow along in this part of the series you must:
- Have read part all previous parts of the series.
- Have all the requirements from previous parts of the series.
When you have all the requirements, we can continue.
Setting up for development
We have already discussed setting up your environment in previous parts of this series so in case you need help please read the previous parts. If you have also been following up with the other parts, you should have already set up a Laravel project.
If you have not set up a project, then you should go back to the previous parts and read them as they give a detailed guide on how to set all set up for this part of the series.
You can still read them!
Creating API routes
In one of the earlier chapters, we spoke about creating RESTful APIs so the techniques mentioned there will be applied here. Let’s create the endpoints for the API of our Trello clone.
In our routes/api.php
file, make sure the file contains the following code:
<?php
Route::post('login', 'UserController@login');
Route::post('register', 'UserController@register');
Route::group(['middleware' => 'auth:api'], function() {
Route::get('/category/{category}/tasks', 'CategoryController@tasks');
Route::resource('/category', 'CategoryController');
Route::resource('/task', 'TaskController');
});
? As a good practice, when creating routes, always put more specific routes ahead of less specific ones. For instance, in the code above the
/category/{category}/tasks
route is above the less specific/category
route.
In the code above, we defined our API routes. Putting the definitions in the routes/api.php
file will tell Laravel that the routes are API routes. Laravel will prefix the routes with a /api
in the URL to differentiate these routes from web routes.
Also in the Route group above, we added a middleware auth:api
, this makes sure that any calls to the routes in that group must be authenticated.
A thing to note is, using the resource
method on the Route
class helps us create some additional routes under the hood. Here is a summary of all the routes available when we added the code above to the file:
Routes for the category resource
Method | URI | Function |
---|---|---|
GET | /api/category | To list all the available categories |
POST | /api/category | To create a new category resource |
DELETE | /api/category/{category_id} | To delete a particular category resource |
GET | /api/category/{category_id} | To fetch a particular category resource |
GET | /api/category/{category}/tasks | To fetch all tasks for particular category |
PUT | /api/category/{category_id} | To update a particular category resource |
Routes for the task resource
Method | URI | Function |
---|---|---|
GET | /api/task | To list all the available tasks |
POST | /api/task | To create a new task resource |
DELETE | /api/task/{task_id} | To delete a particular task |
GET | /api/task/{task_id} | To fetch a particular task resource |
PUT | /api/task/{task_id} | To update a particular task resource |
Routes for the user resource
Method | URI | Function |
---|---|---|
POST | /api/register | Create a new user |
POST | /api/login | Log an existing user in |
? To see the full route list, run the following command:
$ php artisan route:list
.
Now that we have a clear understanding of our routes, let’s see how the controllers will work.
Creating the controller logic
We are going to take a deeper look at the implementation of our different controllers now.
User controller
Since we already created and fully implemented this in the second part of the series, we can skip that and move on to the next controller.
Category controller
Next, open the CategoryController
and replace the contents with the following code:
<?php
namespace App\Http\Controllers;
use App\Category;
use Illuminate\Http\Request;
class CategoryController extends Controller
{
public function index()
{
return response()->json(Category::all()->toArray());
}
public function store(Request $request)
{
$category = Category::create($request->only('name'));
return response()->json([
'status' => (bool) $category,
'message'=> $category ? 'Category Created' : 'Error Creating Category'
]);
}
public function show(Category $category)
{
return response()->json($category);
}
public function tasks(Category $category)
{
return response()->json($category->tasks()->orderBy('order')->get());
}
public function update(Request $request, Category $category)
{
$status = $category->update($request->only('name'));
return response()->json([
'status' => $status,
'message' => $status ? 'Category Updated!' : 'Error Updating Category'
]);
}
public function destroy(Category $category)
{
$status = $category->delete();
return response()->json([
'status' => $status,
'message' => $status ? 'Category Deleted' : 'Error Deleting Category'
]);
}
}
The functions in the controller above handle the basic CRUD operations for the resource. The tasks
methods return tasks associated with a category.
Task controller
Next, open the TaskController
. In this controller, we will manage tasks. A task is given an order value and is linked to a category. Replace the contents with the following code:
<?php
namespace App\Http\Controllers;
use App\Task;
use Illuminate\Http\Request;
class TaskController extends Controller
{
public function index()
{
return response()->json(Task::all()->toArray());
}
public function store(Request $request)
{
$task = Task::create([
'name' => $request->name,
'category_id' => $request->category_id,
'user_id' => $request->user_id,
'order' => $request->order
]);
return response()->json([
'status' => (bool) $task,
'data' => $task,
'message' => $task ? 'Task Created!' : 'Error Creating Task'
]);
}
public function show(Task $task)
{
return response()->json($task);
}
public function update(Request $request, Task $task)
{
$status = $task->update(
$request->only(['name', 'category_id', 'user_id', 'order'])
);
return response()->json([
'status' => $status,
'message' => $status ? 'Task Updated!' : 'Error Updating Task'
]);
}
public function destroy(Task $task)
{
$status = $task->delete();
return response()->json([
'status' => $status,
'message' => $status ? 'Task Deleted!' : 'Error Deleting Task'
]);
}
}
That’s all for the controllers. Since we have already created the models in a previous chapter, let’s move on to creating the frontend.
Building the frontend
Since we are done building the backend, let’s make the frontend using VueJS. To work with Vue, we will need the Vue and Vue router packages we installed in a previous chapter. We will also need the [vuedraggable](https://github.com/SortableJS/Vue.Draggable)
package. To install it, run the command below:
$ npm install vuedraggable --save
Creating the Vue router routes
Since we are building a Single Page App, we are going to set up our vue-router
to handle switching between the different pages of our application. Open the resources/assets/js/app.js
file and replace the contents with the following code:
import Vue from 'vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter)
import App from './views/App'
import Dashboard from './views/Board'
import Login from './views/Login'
import Register from './views/Register'
import Home from './views/Welcome'
const router = new VueRouter({
mode: 'history',
routes: [
{
path: '/',
name: 'home',
component: Home
},
{
path: '/login',
name: 'login',
component: Login,
},
{
path: '/register',
name: 'register',
component: Register,
},
{
path: '/board',
name: 'board',
component: Dashboard,
},
],
});
const app = new Vue({
el: '#app',
components: { App },
router,
});
Next, open the routes/web.php
file and replace the contents with the code below:
<?php
Route::get('/{any}', 'SinglePageController@index')->where('any', '.*');
This will route incoming traffic to the index
method of our SinglePageController
which we created in the previous chapter.
Dealing with authentication
Since our API is secure we’d need access tokens to make calls to it. Tokens are generated and issued when we successfully log in or register. We are going to use localStorage
to hold the token generated by our application so we can very easily get it when we need to make API calls.
Although this is out of the scope of the article it may be worth knowing that contents in local storage are readable from the browser so you might want to make sure your tokens are short-lived and refreshed often.
Let’s set up register component. Create the file resources/assets/js/views/Register.vue
and add the following for the template:
<template>
<div class="container">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card card-default">
<div class="card-header">Register</div>
<div class="card-body">
<form method="POST" action="/register">
<div class="form-group row">
<label for="name" class="col-md-4 col-form-label text-md-right">Name</label>
<div class="col-md-6">
<input id="name" type="text" class="form-control" v-model="name" required autofocus>
</div>
</div>
<div class="form-group row">
<label for="email" class="col-md-4 col-form-label text-md-right">E-Mail Address</label>
<div class="col-md-6">
<input id="email" type="email" class="form-control" v-model="email" required>
</div>
</div>
<div class="form-group row">
<label for="password" class="col-md-4 col-form-label text-md-right">Password</label>
<div class="col-md-6">
<input id="password" type="password" class="form-control" v-model="password" required>
</div>
</div>
<div class="form-group row">
<label for="password-confirm" class="col-md-4 col-form-label text-md-right">Confirm Password</label>
<div class="col-md-6">
<input id="password-confirm" type="password" class="form-control" v-model="password_confirmation" required>
</div>
</div>
<div class="form-group row mb-0">
<div class="col-md-6 offset-md-4">
<button type="submit" class="btn btn-primary" @click="handleSubmit">
Register
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</template>
Then for the script, add the following in the same file below the closing template
tag:
<script>
export default {
data(){
return {
name : "",
email : "",
password : "",
password_confirmation : ""
}
},
methods : {
handleSubmit(e) {
e.preventDefault()
if (this.password === this.password_confirmation && this.password.length > 0)
{
axios.post('api/register', {
name: this.name,
email: this.email,
password: this.password,
c_password : this.password_confirmation
})
.then(response => {
localStorage.setItem('user',response.data.success.name)
localStorage.setItem('jwt',response.data.success.token)
if (localStorage.getItem('jwt') != null){
this.$router.go('/board')
}
})
.catch(error => {
console.error(error);
});
} else {
this.password = ""
this.passwordConfirm = ""
return alert('Passwords do not match')
}
}
},
beforeRouteEnter (to, from, next) {
if (localStorage.getItem('jwt')) {
return next('board');
}
next();
}
}
</script>
In the code above, we have a handleSubmit
method that is called when a user submits the registration form. It sends all the form data to the API, takes the response and saves the jwt
to localStorage
.
We also have a beforeRouterEnter
method which is called by the vue-router before loading a component. In this callback, we check if the user is already logged in and redirect to the application’s board if the user is.
The login component is setup in a similar manner. Create the file resources/assets/js/views/Login.vue
and add the following for the template:
<template>
<div class="container">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card card-default">
<div class="card-header">Login</div>
<div class="card-body">
<form method="POST" action="/login">
<div class="form-group row">
<label for="email" class="col-sm-4 col-form-label text-md-right">E-Mail Address</label>
<div class="col-md-6">
<input id="email" type="email" class="form-control" v-model="email" required autofocus>
</div>
</div>
<div class="form-group row">
<label for="password" class="col-md-4 col-form-label text-md-right">Password</label>
<div class="col-md-6">
<input id="password" type="password" class="form-control" v-model="password" required>
</div>
</div>
<div class="form-group row mb-0">
<div class="col-md-8 offset-md-4">
<button type="submit" class="btn btn-primary" @click="handleSubmit">
Login
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</template>
And for the script, add the following code to the file below the closing template
tag:
<script>
export default {
data(){
return {
email : "",
password : ""
}
},
methods : {
handleSubmit(e){
e.preventDefault()
if (this.password.length > 0) {
axios.post('api/login', {
email: this.email,
password: this.password
})
.then(response => {
localStorage.setItem('user',response.data.success.name)
localStorage.setItem('jwt',response.data.success.token)
if (localStorage.getItem('jwt') != null){
this.$router.go('/board')
}
})
.catch(function (error) {
console.error(error);
});
}
}
},
beforeRouteEnter (to, from, next) {
if (localStorage.getItem('jwt')) {
return next('board');
}
next();
}
}
</script>
That’s all for the Login component.
We need to make a little modification to our application wrapper component. Open the file resources/assets/js/views/App.vue
file and update the file with the following code in the template section:
[...]
<!-- Right Side Of Navbar -->
<ul class="navbar-nav ml-auto">
<!-- Authentication Links -->
<router-link :to="{ name: 'login' }" class="nav-link" v-if="!isLoggedIn">Login</router-link>
<router-link :to="{ name: 'register' }" class="nav-link" v-if="!isLoggedIn">Register</router-link>
<li class="nav-link" v-if="isLoggedIn"> Hi, {{name}}</li>
<router-link :to="{ name: 'board' }" class="nav-link" v-if="isLoggedIn">Board</router-link>
</ul>
[...]
Also, replace the contents of the script
tag in the same file with the following:
export default {
data(){
return {
isLoggedIn : null,
name : null
}
},
mounted(){
this.isLoggedIn = localStorage.getItem('jwt')
this.name = localStorage.getItem('user')
}
}
In the code above, we do a check to see if the user is logged in or not and then use this knowledge to can hide or show route links.
Making secure API calls
Next, let’s create the main application board and consume the meat of the API from there. Create a resources/assets/js/views/Board.vue
file add the following code to the file:
<template>
<div class="container">
<div class="row justify-content-center">
<draggable element="div" class="col-md-12" v-model="categories" :options="dragOptions">
<transition-group class="row">
<div class="col-md-4" v-for="element,index in categories" :key="element.id">
<div class="card">
<div class="card-header">
<h4 class="card-title">{{element.name}}</h4>
</div>
<div class="card-body card-body-dark">
<draggable :options="dragOptions" element="div" @end="changeOrder" v-model="element.tasks">
<transition-group :id="element.id">
<div v-for="task,index in element.tasks" :key="task.category_id+','+task.order" class="transit-1" :id="task.id">
<div class="small-card">
<textarea v-if="task === editingTask" class="text-input" @keyup.enter="endEditing(task)" @blur="endEditing(task)" v-model="task.name"></textarea>
<label for="checkbox" v-if="task !== editingTask" @dblclick="editTask(task)">{{ task.name }}</label>
</div>
</div>
</transition-group>
</draggable>
<div class="small-card">
<h5 class="text-center" @click="addNew(index)">Add new card</h5>
</div>
</div>
</div>
</div>
</transition-group>
</draggable>
</div>
</div>
</template>
In the template above we have implemented the [vue-draggable](https://github.com/SortableJS/Vue.Draggable)
component, we installed earlier. This gives us a draggable div that we can use to mimic how Trello cards can be moved from one board to another. In the draggable
tag we passed some options which we will define in the script
section of the component soon.
To ensure we can drag across multiple lists using vue draggable, we had to bind our categories
attribute to the parent draggable
component. The most important part is binding the element.tasks
to the child draggable component as a prop
using v-model
. If we fail to bind this, we would not be able to move items across the various categories we have.
We also define a method to be called when the dragging of an item is done (@end
), when we click to edit an item or when we click the Add New Card.
For our style add the following after the closing template
tag:
<style scoped>
.card {
border:0;
border-radius: 0.5rem;
}
.transit-1 {
transition: all 1s;
}
.small-card {
padding: 1rem;
background: #f5f8fa;
margin-bottom: 5px;
border-radius: .25rem;
}
.card-body-dark{
background-color: #ccc;
}
textarea {
overflow: visible;
outline: 1px dashed black;
border: 0;
padding: 6px 0 2px 8px;
width: 100%;
height: 100%;
resize: none;
}
</style>
Right after the code above, add the following code:
<script>
import draggable from 'vuedraggable'
export default {
components: {
draggable
},
data(){
return {
categories : [],
editingTask : null
}
},
methods : {
addNew(id) {
},
loadTasks() {
},
changeOrder(data){
},
endEditing(task) {
},
editTask(task){
this.editingTask = task
}
},
mounted(){
},
computed: {
dragOptions () {
return {
animation: 1,
group: 'description',
ghostClass: 'ghost'
};
},
},
beforeRouteEnter (to, from, next) {
if ( ! localStorage.getItem('jwt')) {
return next('login')
}
next()
}
}
</script>
Loading our categories
Let’s load our categories as we mount the Board
component. Update the mounted
method of the same file to have the following:
mounted() {
let token = localStorage.getItem('jwt')
axios.defaults.headers.common['Content-Type'] = 'application/json'
axios.defaults.headers.common['Authorization'] = 'Bearer ' + token
axios.get('api/category').then(response => {
response.data.forEach((data) => {
this.categories.push({
id : data.id,
name : data.name,
tasks : []
})
})
this.loadTasks()
})
},
In the code above, we set up axios
. This is very important because vue
will call the mounted
method first before the page loads, so it is a convenient way to actually load data we need to use on our page.
? We set up some default
axios
headers so we no longer need to pass the headers for each call we make.
Loading our tasks
Now we can add the logic to load the tasks from a category. In the methods
object of the Board
component, update the loadTasks
method to the following code:
[...]
loadTasks() {
this.categories.map(category => {
axios.get(`api/category/${category.id}/tasks`).then(response => {
category.tasks = response.data
})
})
},
[...]
Adding new tasks
Let’s add the logic to add new tasks. In the methods
object of the Board
component, update the addNew
method to the following:
[...]
addNew(id) {
let user_id = 1
let name = "New task"
let category_id = this.categories[id].id
let order = this.categories[id].tasks.length
axios.post('api/task', {user_id, name, order, category_id}).then(response => {
this.categories[id].tasks.push(response.data.data)
})
},
[...]
When the addNew
method is called the id
of the category is passed in, which helps us determine where the new task should be added. We create the task for that category and pass in a dummy text as a placeholder so the user can see it come up.
Editing tasks
We will now add the logic to edit tasks. In the methods
object of the Board
component, update the endEditing
method to the following:
[...]
endEditing(task) {
this.editingTask = null
axios.patch(`api/task/${task.id}`, {name: task.name}).then(response => {
// You can do anything you wan't here.
})
},
[...]
When a task is edited, we pass it to the endEditing
method which sends it over to the API.
Reordering tasks
Now we can get the logic to reorder tasks. In the methods
object of the Board
component, update the changeOrder
method to the following:
[...]
changeOrder(data) {
let toTask = data.to
let fromTask = data.from
let task_id = data.item.id
let category_id = fromTask.id == toTask.id ? null : toTask.id
let order = data.newIndex == data.oldIndex ? false : data.newIndex
if (order !== false) {
axios.patch(`api/task/${task_id}`, {order, category_id}).then(response => {
// Do anything you want here
});
}
},
[...]
Draggable
returns an object when you drop an element you dragged. The returned object contains information of where the element was moved from and where it was dropped. We use this object to determine which category a task was moved from.
? If you go over the draggable component again, you’d notice we bound
:id
when we were rendering categories. This is the sameid
referenced above.
Build the application
The next thing we need to do is build the assets. Run the command below to build the application:
$ npm run prod
? Using
prod
will optimize the build. Recommended especially when you want to build for production. The other value available here isdev
which is used during the development process
When the build is complete, we can now run the application:
$ php artisan serve
Conclusion
In this series, we have seen how to build a simple Trello clone and in the process explained some key concepts you need to know when building modern web applications using Laravel and Vue.