SPA Authentication using Laravel 9 Sanctum, Vue 3 and Vite - TechvBlogs

SPA Authentication using Laravel 9 Sanctum, Vue 3 and Vite

In this blog, together we will create a complete register and login feature for a single-page application with Laravel 9 Sanctum, Bootstrap5, Vue 3 and Vite.


Suresh Ramani - Author - TechvBlogs
Suresh Ramani
 

1 year ago

TechvBlogs - Google News

Laravel Sanctum provides a lightweight authentication system relying on Laravel's built-in cookie-based session authentication services.

How Laravel Sanctum works

Before we start blindly mashing away without an understanding of what's happening behind the scenes, let's run over how Sanctum works.

Laravel Sanctum uses Laravel's cookie-based session authentication to authenticate users from your client. Here's the flow.

  1. You request a CSRF cookie from Sanctum on the client, which allows you to make CSRF-protected requests to normal endpoints like / login.
  2. You make a request to the normal Laravel / login endpoint.
  3. Laravel issues a cookie holding the user's session.
  4. Any requests to your API now include this cookie, so your user is authenticated for the lifetime of that session.

SPA Authentication using Laravel 9 Sanctum, Vue 3 and Vite Example:

  1. Create Laravel Project
  2. Configure Database Detail
  3. Install laravel/ui
  4. Install Vue 3
  5. Install vitejs/plugin-vue plugin
  6. Update vite.config.js file
  7. Import Bootstrap Path in vite.config.js
  8. Install NPM Dependencies
  9. Update bootstrap.js
  10. Import Bootstrap 5 SCSS in JS Folder
  11. Vite Dev Server Start
  12. Install Laravel Sanctum
  13. Configure Laravel Sanctum
  14. Migrate Database
  15. Setup Frontend

Requirements

  1. PHP ^8.0
  2. Laravel ^9.0
  3. MySQL
  4. Bootstrap 5
  5. Vue 3
  6. Vite

In this blog, together we will create a complete register and login feature for a single-page application with Laravel 9 Sanctum, Bootstrap5, Vue 3 and Vite.

Step 1: Create Laravel Project

First, open Terminal and run the following command to create a fresh Laravel project:

composer create-project --prefer-dist laravel/laravel:^9.0 lara9sanctum-vue3-vite

or, if you have installed the Laravel Installer as a global composer dependency:

laravel new lara9sanctum-vue3-vite

Step 2: Configure Database Detail

open .env and update database detail

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

Step 3: Install laravel/ui

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

Step 4: Install Vue 3

Now after installing node modules we need to install vue 3 in our application, for that execute the following command in the terminal npm install vue@next vue-loader@next. vue-loader is a loader for webpack that allows you to author Vue components in a format called Single-File Components. vue-loader@next is a loader that is for webpack to author Vue components in single-file components called SFCs.

npm install vue@next vue-loader@next

Step 5: Install vitejs/plugin-vue plugin

In laravel 9 latest release install vitejs/plugin-vue plugin for installing vue3 or vue in laravel. This plugin provides required dependencies to run the vuejs application on vite. Vite is a  build command that bundles your code with Rollup and runs of localhost:3000 port to give hot refresh feature.

npm i @vitejs/plugin-vue

Step 6: Update vite.config.js file

Vite is a module bundler for modern JavaScript applications. Open vite.config.js and copy-paste the following code. First invoice defineConfig from vite at the top of the file and also import laravel-vite-plugin. Here plugins() take the path of the js and CSS file and create bundles for your application. you need to add vue() in the plugins array.

// 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',
        ]),
    ],
});

Step 7: Import Bootstrap Path in vite.config.js

First, you need to change vite.config.js and add the bootstrap 5 path & remove 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',
        }
    },
});

Step 8: Install NPM Dependencies

Run the following command to install frontend dependencies:

npm install

Step 9: Update bootstrap.js

We need to use import instead of require.

import loadash from 'lodash'
window._ = loadash

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

import 'bootstrap'

/**
 * We'll load the axios HTTP library which allows us to easily issue requests
 * to our Laravel back-end. This library automatically handles sending the
 * CSRF token 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 exposes an expressive API for subscribing to channels and listening
 * for events that are broadcast by Laravel. Echo and event broadcasting
 * allows your team to easily build robust real-time web applications.
 */

/*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
});*/

Step 10: Import Bootstrap 5 SCSS in JS Folder

Now you need to import bootstrap 5 SCSS path in resources/js/app.js

resources/js/app.js

import './bootstrap';

import '../sass/app.scss'

Step 11: Vite Dev Server Start

Now after installing the vue 3, we need to start the dev server for vite for that run the following command and it will watch your resources/js/app.js file and resources/css/app.css file. It also starts a vite server on http://localhost:3000. you can not open it in the browser as it is for vite hot reload and it runs in the background and watches the assets of your application like js and CSS.

npm run dev

Step 12: Install Laravel Sanctum

You can find documentation on the Official Laravel Website.

composer require laravel/sanctum

Step 13: Configure Laravel Sanctum

Open config/sanctum.php and update the following code:

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

You will need to change this when deploying to production, so adding  SANCTUM_STATEFUL_DOMAINS to your  .env file with a comma-separated list of allowed domains is a great idea.

Open .env file and add this line

SANCTUM_STATEFUL_DOMAINS=localhost:<PORT NUMBER>

Change the session driver

In .env, update session driver file to cookie.

SESSION_DRIVER=cookie

Configure CORS

Open config/cors.php and update the following code into the file:

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

Also set supports_credentials option to true:

'supports_credentials' => true,

Let's create our Vue component that will hold our login form and display some secrets.

Step 14: Migrate Database

php artisan migrate

Step 15: Setup Frontend

When we generated our frontend code earlier using  php artisan ui vue , an example component was generated under  resources/js/components/ExampleComponent.vue. Let's create other components for Login, Register, and Dashboard Page.

What is Vue Router?

Vue Router helps link between the browser's URL / History and Vue's components allowing for certain paths to render whatever view is associated with it.

Features Of Vue Router

  • Nested Routes
  • Route params, query
  • Dynamic Routes Matching
  • Links with automatic active CSS classes
  • and many more

Let's install vue-router

npm install vue-router

Now, 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>

Create 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>

Create Layout Component For All Authenticated Pages. So we don't need to add header, footer, and any other component in all pages component so here we created a layout component named  Dashboard.vue. Here in the component, We add header, footer, and router-view so every component will render in this 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://techvblogs.com/blog/spa-authentication-laravel-9-sanctum-vue3-vite" target="_blank">TechvBlogs</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>

Now add this 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')

Before we make these requests, we'll need to set a base URL for our API (notice these are not included in the requests we have right now) and also enable the  withCredentials option.

Open resources/js/bootstrap.jsand add the following code into that file:

window.axios.defaults.withCredentials = true

The  withCredentials an option is really important here. This Axios instructs to automatically send our authentication cookie along with every request.

What is Vuex?

Vuex is a state management pattern + library for Vue. js applications. It serves as a centralized store for all the components in an application, with rules ensuring that the state can only be mutated in a predictable fashion.

Well, since we want to hold an overall authenticated 'state' in our client, using a state management library like Vuex makes sense here. It'll also allow us to easily check within any component if we're authenticated or not (e.g. our navigation).

Let's Install Vuex

npm install vuex --save

First, create a resources/js/store/auth.js file with the following.

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)
        }
    }
}

The state the property holds whether we're authenticated or not, and holds the user details we'll be fetching once authenticated.

Our getters return to us that state.

Our mutations update our state. For example, once we're successfully authenticated, we'll commit a mutation to set authenticated to true and commit another mutation to set the user's details.

Sometimes we need our VueJS Web App to persist some information in browser local storage. It could be local settings, account info, or some tokens. We definitely don’t want to lose them once the page is refreshed. That's why we need to use vuex-persistedstate.

Install vuex-persistedstate

npm i vuex-persistedstate

Now add the auth module to Vuex in resources/js/store/index.js.

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 this 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>SPA Authentication using Laravel 9 Sanctum, Vue 3 and Vite - TechvBlogs</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>

Now define routes in web.php and api.php routes file. Go to routes folder and open web.php file and update the following routes:

routes / web.php

<?php

use Illuminate\Support\Facades\Route;

/*
|--------------------------------------------------------------------------
| Web Routes
|--------------------------------------------------------------------------
|
| Here is where you can register web routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| contains the "web" middleware group. Now create something great!
|
*/

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

Auth::routes();

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

Now, it's time to run our project.

php artisan serve

Open localhost:<PORT NUMBER> in the browser.

It'd be a good idea to follow along with the simple demo app that can be found in this GitHub repo.

Thank you for reading this blog.

Read Also: How To Install Vue 3 in Laravel 9 with Vite

If you have any queries or doubts about this topic please feel free to contact us. We will try to reach you.

Comments (15)

Ishara Miyushan Ishara Miyushan 5 months ago

So how to get the user object after logging in?

Fabian Fabian 1 year ago

Thanks for this work! I have only one issue with data prop. It return html code instead the registered data user. How can obtain the complete data from registered user?. Thanks

Nige Nige 1 year ago

This doesn't seem to work for me. As soon as I install the laravel/ui component and run the vue-auth I got a bunch of 'failed to remove some directories' errors that I can't get around. This is preventing me from running any form of npm install commands and I can't go any further.

Pity, I was looking forward to this tutorial.

Raihanul Kabir Raihanul Kabir 1 year ago

Hello bro thanks for your tutorial. Can you please make a tutorial for multi user authentication like admin and user and when they are login dashboard are automatically changes for user and admin using Laravel 9 and Vue 3 . please bro make a tutorial.

Omar Omar 1 year ago

Can you please do the same thing with vuetify.

James James 1 year ago

I'm making a call an an api endpoint I added, and it seems like the vite proxy (?) adds headers to the response

HTTP/1.0 200 OK Cache-Control: no-cache, private Content-Type: application/json Date: Fri, 09 Dec 2022 23:13:42 GMT

{"token":"eyJ2ZXJzaW9 ...

Any idea how to remove those?

James James 1 year ago

I commented out that line, and register worked. But, I get csrf mismatch error on login now ..

James James 1 year ago

.. and I restarted the server, and no longer see the csrf error. Another thing, though, is that I'm seeing this "You are logged in as " on the home page - with a blank space where my email address should be .. btw, thanks for this!

Shashi Shashi 11 months ago

Even I get the same error.

Fernando Fernando 7 months ago

Same error here. If anyone knows how to correct it, please share with us.

esse quam videri esse quam videri 1 year ago

Hi, where should I put my additional pages? how to add a bootstrap template, like an admin template? sorry I'm still learning on this

Amit kumar Amit kumar 1 year ago

Sorry bro but tumhara code run nahi kr rha h

Mirko Mirko 1 year ago

Hi! Thanks for your tutorial!

For anyone following this on a Laravel 9 brand new installation, please note that you'll need to remove the following code from routes/api.php:

Route::middleware('auth:sanctum')->get('/user', function (Request $request) { return $request->user(); });

otherwise you'll keep getting a 401 everytime you'll try to register a new user :)

Developer.AumTechnology Developer.AumTechnology 1 year ago

Yes That's fine

Thank You.

Szabolcs Zsoldos Szabolcs Zsoldos 1 year ago

I was thinking the same, thanks for the heads up.

Comment


Note: All Input Fields are required.