laramatic.com

Laravel 9 Sanctum Vue 3 Vite SPA Authentication

In this tutorial we are using Laravel 9 Sanctum Vue 3 Vite SPA Authentication with BootStrap 5 to create a single page application and user register, login and dashboard components. This walkthrough will help you create a single page app registration, login and auth using Vue 3 BootStrap 5 and Laravel 9 Sanctum with Vite. There are few things that we need to understand first about Laravel 9 Sanctum while using it with Vue 3 and Vite which are mentioned below before starting over:

Requirements for these examples 

  • PHP 8.0
  • Laravel 9
  • Vue 3
  • BootStrap 5
  • Vite
  • MySQL

What is Laravel Sanctum?

Laravel Sanctum is a PHP framework which gives a very lightweight authentication system for SAPs (Single Page Apps) using session cookies that’s a built-in Laravel 9 feature for verification and authentication. 

  • When system sends a request for a CSRF cookie-based verification from Sanctum, it lets system have CSRF-protected requests for a user’s login 
  • The system sends a request for a normal user login from Laravel at the end 
  • Laravel verifies it with the session cookies of that user 
  • Now the system recognizes it as the request is made to the API which has that cookie so the user session starts which is verified now 

Creating Laravel 9 Sanctum, Vue 3 and Vite for a Single Page App Authentication Walkthrough with Examples  

  1. Starting a New Laravel 9 Project 
  2. Configuring & Updating The Database
  3. Installing Laravel 9 UI
  4. Installing Vue 3 
  5. Installing the ViteJS/plugin-vue plugin
  6. Updating the file vite.config.js 
  7. Importing Bootstrap 5 Path in vite.config.js 
  8. Installing NPM Dependencies 
  9. Updating bootstrap.js
  10. Importing Bootstrap 5 SCSS in JS Folder
  11. Starting Vite Dev Server 
  12. Installing Laravel 9 Sanctum
  13. Configuring Laravel 9 Sanctum
  14. Migrating Database
  15. Setting up Frontend

Let’s use Laravel 9 Sanctum, Bootstrap 5, Vue 3 and Vite and create a register and login feature for SPA app:

#1 Starting a New Laravel 9 Project 

Create a new Laravel 9 project in your terminal by using this command

composer create-project --prefer-dist laravel/laravel:^9.0 laravel-9-sanctum-vue-3-vite-spa-authentication

If Laravel 9 is already installed as a global composer then use this:

laravel new laravel-9-sanctum-vue-3-vite-spa-authentication

#2 Configuring & Updating The Database

Now configure and update the database by using .env

DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=<DATABASE NAME>
DB_USERNAME=<DATABASE USERNAME>
DB_PASSWORD=<DATABASE PASSWORD>

#3 Installing Laravel 9 UI

composer require laravel/ui
php artisan ui vue --auth

#4 Installing Vue 3

The next we will install Vue 3 and to do that use this command. 

npm install vue@next vue-loader@next

We are using Vue-loaders webpack loader to author Vue components in a Single File Components format. 

#5 Installing the ViteJS/plugin-vue plugin

To install vue 3 or Vue in Laravel 9 we are installing vitejs/plugin-vue plugin. We will use this plugin to run our vuejs app on vite as it provides much needed dependencies for it. While vite runs on localhost:3000 and provides new features and it also wraps our code with Rollup. 

 npm i @vitejs/plugin-vue

#6 Updating vite.config.js

When we open vite.config.js and copy paste the code we have mentioned below. The defineConfig is the first invoice from ite and now import laravel-vite-plugin. The plugins() will use the JS and CSS path and also create bundle for our app. We have to include vue() in our array of plugins. First of all these things we have open vite.config.js and copy paste it. 

// vite.config.js
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import vue from '@vitejs/plugin-vue'


export default defineConfig({
    plugins: [
        vue(),
        laravel([
            'resources/js/app.js',
        ]),
    ],
});

#7 Importing BootStrap 5 path in vite.config.js

Now the most important step that we have to do first is to change vite.config.js by adding bootstrap 5 path and removing resources/css/app.css


import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import vue from '@vitejs/plugin-vue'
import path from 'path'

export default defineConfig({
    plugins: [
        vue(),
        laravel([
            'resource/scss/app.scss',
            'resources/js/app.js',
        ]),
    ],
    resolve: {
        alias: {
            '~bootstrap': path.resolve(__dirname, 'node_modules/bootstrap'),
            '@': '/resources/js',
        }
    },
});

#8 Installing NPM Dependencies

Use this command in your terminal to install npm dependencies

npm install

#9 Updating bootstrap.js

We are to use import

import loadash from 'lodash'
window._ = loadash

import * as Popper from '@popperjs/core'
window.Popper = Popper

import 'bootstrap'

/**
 * To issue requests to our Laravel back-end we will load axios HTTP library. 
 * It will send CSRF tokens
 * as a header based on the value of the "XSRF" token cookie.
 */

import axios from 'axios'
window.axios = axios

window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';

/**
 * Echo uses APIs to express events such as subscriptions and other notifications
 * which are broadcast by Laravel. The developers can build real-time web apps based 
 * on these events.  
*/

/*import Echo from 'laravel-echo';

window.Pusher = require('pusher-js');

window.Echo = new Echo({
     broadcaster: 'pusher',
     key: process.env.MIX_PUSHER_APP_KEY,
     cluster: process.env.MIX_PUSHER_APP_CLUSTER,
     forceTLS: true
});*/

#10 Importing BootStrap 5 SCSS in JS Folder

We are importing BootStrap 5 SCSS path in our resources/js/app.js which we have to. Run this command for that:
resources/js/app.js

import './bootstrap';

import '../sass/app.scss'

#11 Starting Vite Dev Server

We will run the following command to start vite dev server that looks at our resources/js/app.js file and resources/css/app.css file. It starts vite server on http://localhost:3000. It runs in the background so we cannot access it through our browser as we use it for vite hot reload and it keep account of our app assets such as CSS and JS.

 npm run dev

#12 Installing Laravel 9 Sanctum

We will install Laravel 9 Sanctum using its package manage.

composer require laravel/sanctum

#13 Configuring Laravel 9 Sanctum

We need to update the following code in config/sanctum.php:

'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', 'localhost,127.0.0.1')),

We need to change this by adding one more thing SANCTUM_STATEFUL_DOMAINS to our .env file for production deployment and separated domains by adding commas. Open .env file and add this line

SANCTUM_STATEFUL_DOMAINS=localhost:<PORT NUMBER>

Now change the session driver as well
In .env, update session driver file to cookie.

SESSION_DRIVER=cookie

Let’s Configure CORS Now
Open config/cors.php and update this path into the file:



'paths' => [
    'api/*',
    '/login',
    '/logout',
    '/sanctum/csrf-cookie'
],

Set the supports_credentials true

'supports_credentials' => true,

Now comes the Vue component that will do most important job for us that will display the secret and hold our logins.

#14 Migrating Database

Type this command to migrate the database

   php artisan migrate

#15 Setting up Frontend

We know that as we generated the frontend code with php artisan ui vue an example component was also there generated with that as resources/js/components/ExampleComponent.vue. We are going to more such components for our page’s Register, Login and Dashboard.

First we need to understand basics of Vue Router which sets up path for browser history and URL associated with the view. Vue Router features included, Nested Routes, Route params, query, Dynamic Routes Matching ad links with automated active CSS classes.

Now installing vue_router

npm install vue-router

Now, we will create components For Login and Register. Create a File inside resources/js/components folder name with Login.vue. resources/js/components/Login.vue

<template>
    <div class="container h-100">
        <div class="row h-100 align-items-center">
            <div class="col-12 col-md-6 offset-md-3">
                <div class="card shadow sm">
                    <div class="card-body">
                        <h1 class="text-center">Login</h1>
                        <hr/>
                        <form action="javascript:void(0)" class="row" method="post">
                            <div class="col-12" v-if="Object.keys(validationErrors).length > 0">
                                <div class="alert alert-danger">
                                    <ul class="mb-0">
                                        <li v-for="(value, key) in validationErrors" :key="key">{{ value[0] }}</li>
                                    </ul>
                                </div>
                            </div>
                            <div class="form-group col-12">
                                <label for="email" class="font-weight-bold">Email</label>
                                <input type="text" v-model="auth.email" name="email" id="email" class="form-control">
                            </div>
                            <div class="form-group col-12 my-2">
                                <label for="password" class="font-weight-bold">Password</label>
                                <input type="password" v-model="auth.password" name="password" id="password" class="form-control">
                            </div>
                            <div class="col-12 mb-2">
                                <button type="submit" :disabled="processing" @click="login" class="btn btn-primary btn-block">
                                    {{ processing ? "Please wait" : "Login" }}
                                </button>
                            </div>
                            <div class="col-12 text-center">
                                <label>Don't have an account? <router-link :to="{name:'register'}">Register Now!</router-link></label>
                            </div>
                        </form>
                    </div>
                </div>
            </div>
        </div>
    </div>
</template>

<script>
import { mapActions } from 'vuex'
export default {
    name:"login",
    data(){
        return {
            auth:{
                email:"",
                password:""
            },
            validationErrors:{},
            processing:false
        }
    },
    methods:{
        ...mapActions({
            signIn:'auth/login'
        }),
        async login(){
            this.processing = true
            await axios.get('/sanctum/csrf-cookie')
            await axios.post('/login',this.auth).then(({data})=>{
                this.signIn()
            }).catch(({response})=>{
                if(response.status===422){
                    this.validationErrors = response.data.errors
                }else{
                    this.validationErrors = {}
                    alert(response.data.message)
                }
            }).finally(()=>{
                this.processing = false
            })
        },
    }
}
</script>

Now creating a file inside resources/js/components folder name with Register.vue.

<template>
    <div class="container h-100">
        <div class="row h-100 align-items-center">
            <div class="col-12 col-md-6 offset-md-3">
                <div class="card shadow sm">
                    <div class="card-body">
                        <h1 class="text-center">Register</h1>
                        <hr/>
                        <form action="javascript:void(0)" @submit="register" class="row" method="post">
                            <div class="col-12" v-if="Object.keys(validationErrors).length > 0">
                                <div class="alert alert-danger">
                                    <ul class="mb-0">
                                        <li v-for="(value, key) in validationErrors" :key="key">{{ value[0] }}</li>
                                    </ul>
                                </div>
                            </div>
                            <div class="form-group col-12">
                                <label for="name" class="font-weight-bold">Name</label>
                                <input type="text" name="name" v-model="user.name" id="name" placeholder="Enter name" class="form-control">
                            </div>
                            <div class="form-group col-12 my-2">
                                <label for="email" class="font-weight-bold">Email</label>
                                <input type="text" name="email" v-model="user.email" id="email" placeholder="Enter Email" class="form-control">
                            </div>
                            <div class="form-group col-12">
                                <label for="password" class="font-weight-bold">Password</label>
                                <input type="password" name="password" v-model="user.password" id="password" placeholder="Enter Password" class="form-control">
                            </div>
                            <div class="form-group col-12 my-2">
                                <label for="password_confirmation" class="font-weight-bold">Confirm Password</label>
                                <input type="password_confirmation" name="password_confirmation" v-model="user.password_confirmation" id="password_confirmation" placeholder="Enter Password" class="form-control">
                            </div>
                            <div class="col-12 mb-2">
                                <button type="submit" :disabled="processing" class="btn btn-primary btn-block">
                                    {{ processing ? "Please wait" : "Register" }}
                                </button>
                            </div>
                            <div class="col-12 text-center">
                                <label>Already have an account? <router-link :to="{name:'login'}">Login Now!</router-link></label>
                            </div>
                        </form>
                    </div>
                </div>
            </div>
        </div>
    </div>
</template>

<script>
import { mapActions } from 'vuex'
export default {
    name:'register',
    data(){
        return {
            user:{
                name:"",
                email:"",
                password:"",
                password_confirmation:""
            },
            validationErrors:{},
            processing:false
        }
    },
    methods:{
        ...mapActions({
            signIn:'auth/login'
        }),
        async register(){
            this.processing = true
            await axios.get('/sanctum/csrf-cookie')
            await axios.post('/register',this.user).then(response=>{
                this.validationErrors = {}
                this.signIn()
            }).catch(({response})=>{
                if(response.status===422){
                    this.validationErrors = response.data.errors
                }else{
                    this.validationErrors = {}
                    alert(response.data.message)
                }
            }).finally(()=>{
                this.processing = false
            })
        }
    }
}
</script>

Now We Have to Create Layout Component For All Authenticated Pages

We would not have to add header, footer and other components in every single page. We have created a layout component Dashboard.vue where we are adding header, footer and router-view that will be used by all components from router-view.

resources/js/components/layouts/Default.vue
<template>
    <div>
        <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
            <div class="container-fluid">
                <a class="navbar-brand" href="https://laramatic.com/laravel-9-sanctum-vue-3-vite-spa-authentication" target="_blank">laraMatic</a>
                <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNavDropdown" aria-controls="navbarNavDropdown" aria-expanded="false" aria-label="Toggle navigation">
                    <span class="navbar-toggler-icon"></span>
                </button>
                <div class="collapse navbar-collapse" id="navbarNavDropdown">
                    <ul class="navbar-nav me-auto">
                        <li class="nav-item">
                            <router-link :to="{name:'dashboard'}" class="nav-link">Home <span class="sr-only">(current)</span></router-link>
                        </li>
                    </ul>
                    <div class="d-flex">
                        <ul class="navbar-nav">
                            <li class="nav-item dropdown">
                                <a class="nav-link dropdown-toggle" href="#" id="navbarDropdownMenuLink" role="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
                                    {{ user.name }}
                                </a>
                                <div class="dropdown-menu dropdown-menu-end" aria-labelledby="navbarDropdownMenuLink">
                                    <a class="dropdown-item" href="javascript:void(0)" @click="logout">Logout</a>
                                </div>
                            </li>
                        </ul>
                    </div>
                </div>
            </div>
        </nav>
        <main class="mt-3">
            <router-view></router-view>
        </main>
    </div>
</template>

<script>
import {mapActions} from 'vuex'
export default {
    name:"default-layout",
    data(){
        return {
            user:this.$store.state.auth.user
        }
    },
    methods:{
        ...mapActions({
            signOut:"auth/logout"
        }),
        async logout(){
            await axios.post('/logout').then(({data})=>{
                this.signOut()
                this.$router.push({name:"login"})
            })
        }
    }
}
</script>
resources/js/components/Dashboard.vue
<template>
    <div class="container">
        <div class="row">
            <div class="col-12">
                <div class="card shadow-sm">
                    <div class="card-header">
                        <h3>Dashboard</h3>
                    </div>
                    <div class="card-body">
                        <p class="mb-0">You are logged in as <b>{{user.email}}</b></p>
                    </div>
                </div>
            </div>
        </div>
    </div>
</template>

<script>
export default {
    name:"dashboard",
    data(){
        return {
            user:this.$store.state.auth.user
        }
    }
}
</script>

The next we have to add the page component to the router. Create a new file resources/js/router/index.js

import { createWebHistory, createRouter } from 'vue-router'
import store from '@/store'

/* Guest Component */
const Login = () => import('@/components/Login.vue')
const Register = () => import('@/components/Register.vue')
/* Guest Component */

/* Layouts */
const DahboardLayout = () => import('@/components/layouts/Default.vue')
/* Layouts */

/* Authenticated Component */
const Dashboard = () => import('@/components/Dashboard.vue')
/* Authenticated Component */


const routes = [
    {
        name: "login",
        path: "/login",
        component: Login,
        meta: {
            middleware: "guest",
            title: `Login`
        }
    },
    {
        name: "register",
        path: "/register",
        component: Register,
        meta: {
            middleware: "guest",
            title: `Register`
        }
    },
    {
        path: "/",
        component: DahboardLayout,
        meta: {
            middleware: "auth"
        },
        children: [
            {
                name: "dashboard",
                path: '/',
                component: Dashboard,
                meta: {
                    title: `Dashboard`
                }
            }
        ]
    }
]

const router = createRouter({
    history: createWebHistory(),
    routes, // short for `routes: routes`
})

router.beforeEach((to, from, next) => {
    document.title = to.meta.title
    if (to.meta.middleware == "guest") {
        if (store.state.auth.authenticated) {
            next({ name: "dashboard" })
        }
        next()
    } else {
        if (store.state.auth.authenticated) {
            next()
        } else {
            next({ name: "login" })
        }
    }
})
export default router
Add router into resources/js/app.js
import './bootstrap';
import '../sass/app.scss'
import Router from '@/router'

import { createApp } from 'vue/dist/vue.esm-bundler';

const app = createApp({})
app.use(Router)
app.mount('#app')

We cannot initiate our requests before setting up the base URL for our API and enable withCredential option. We don’t have base URL for our API in our requests.

Open resources/js/bootstrap.js and add this code into that file:

window.axios.defaults.withCredentials = true

For authentication we need to have withCredentials enabled, this Axios instructs for sending cookies automatically to verify the requests.

What is Vuex?

Vuex is a vue.js apps library that also manages patterns. It’s a central store for every component used in every app with rules dictating that the state can only be mutated in a predictable fashion.

Since we have to hold the overall authenticated ‘state’ in our client, using Vuex is very crucial for us. It allows us to verify authentication within any component such as our navigation.

Installing Vuex

npm install vuex --save

Let’s create a resources/js/store/auth.js file first with this command:

import axios from 'axios'
import router from '@/router'

export default {
    namespaced: true,
    state:{
        authenticated:false,
        user:{}
    },
    getters:{
        authenticated(state){
            return state.authenticated
        },
        user(state){
            return state.user
        }
    },
    mutations:{
        SET_AUTHENTICATED (state, value) {
            state.authenticated = value
        },
        SET_USER (state, value) {
            state.user = value
        }
    },
    actions:{
        login({commit}){
            return axios.get('/api/user').then(({data})=>{
                commit('SET_USER',data)
                commit('SET_AUTHENTICATED',true)
                router.push({name:'dashboard'})
            }).catch(({response:{data}})=>{
                commit('SET_USER',{})
                commit('SET_AUTHENTICATED',false)
            })
        },
        logout({commit}){
            commit('SET_USER',{})
            commit('SET_AUTHENTICATED',false)
        }
    }
}

We will fetch the details about the users that are held in the state once we are authenticated.

Our getters return to us that state our mutations will also update our state once we are authenticated. We will do two things during the mutation: set the authentication to true and set user details.

We can also make our VueJS Web App to hold/persist the information in browser’s local storage for authentication. It could be anything such as settings, details or tokens etc so that we don’t lose the details once page reloads. So vuex-persistedstate is very useful.

Use the command below to install vuex-persistedstate

npm i vuex-persistedstate

We have to add auth module to Vuex in resources/js/store/index.js right now.

import { createStore } from 'vuex'
import createPersistedState from 'vuex-persistedstate'
import auth from '@/store/auth'

const store = createStore({
    plugins:[
        createPersistedState()
    ],
    modules:{
        auth
    }
})

export default store

Add Vuex into resources/js/app.js

import './bootstrap';
import '../sass/app.scss'
import Router from '@/router'
import store from '@/store'

import { createApp } from 'vue/dist/vue.esm-bundler';

const app = createApp({})
app.use(Router)
app.use(store)
app.mount('#app')

open resources/views/welcome.blade.php and replace the following code:

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">

        <title>Laravel 9 Sanctum Vue 3 Vite SPA Authentication - LaraMatic</title>

        <!-- Fonts -->
        <link href="https://fonts.bunny.net/css2?family=Nunito:wght@400;600;700&display=swap" rel="stylesheet">

        @vite(['resources/js/app.js'])
    </head>
    <body>
        <div id="app">
            <router-view></router-view>
        </div>
    </body>
</html>

The next we will define the routes in our web.php and api.php routes file. We need to open routes folder and open web.php file and update the following routes:

routes / web.php

<?php

use Illuminate\Support\Facades\Route;

/*
|--------------------------------------------------------------------------
| Web Routes
|--------------------------------------------------------------------------
|
| Here we can register our web routes for our app and you can too for the same reason. They
| get loaded by the RouteServiceProvider within a group which
| contains the "web" middleware group. 
|
*/

Route::get('{any}', function () {
    return view('welcome');
})->where('any', '.*');

Auth::routes();

Route::get('/home', [App\Http\Controllers\HomeController::class, 'index'])->name('home');

We have completed all the steps needed for that. If you have not encountered errors it should run smoothly. Let’s run our project.

php artisan serve

Now we will open localhost:<PORT NUMBER> in the browser and see.