Laravel 12 + Vue 3 Authentication Tutorial | SPA Login with Sanctum

Laravel 12 + Vue 3 Authentication Tutorial | SPA Login with Sanctum

Introduction

This tutorial teaches you how to build a secure SPA using Laravel 12 as backend API and Vue 3 as frontend in a single project.

Authentication is done with Laravel Sanctum using cookie-based sessions — no JWT needed on frontend.

Why Laravel Sanctum?

  • ✅ No JWT handling on frontend

  • ✅ No CORS issues

  • ✅ Built-in CSRF protection

  • ✅ Secure session-based authentication

  • ✅ Production-ready setup

What You Will Build

  • User Registration & Login

  • Authenticated User Profile

  • Logout functionality

  • Protected API routes

  • Vue 3 SPA frontend

  • Laravel 12 API backend

Prerequisites

  • PHP 8.1+

  • Composer

  • Node.js & npm

  • MySQL

  • Basic knowledge of Laravel & Vue

Step 1: Create Laravel Project

composer create-project laravel/laravel laravel-vue-auth "12.*" cd laravel-vue-auth

Step 2: Setup Database

  1. Create a database named laravel_vue_auth in MySQL.

  2. Edit .env file:

DB_DATABASE=laravel_vue_auth DB_USERNAME=root DB_PASSWORD=your_password_here
  1. Run migrations:

php artisan install:api php artisan migrate

Step 3: Install Laravel Sanctum

composer require laravel/sanctum php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider" php artisan migrate

Step 4: Configure CORS

Edit config/cors.php:

return [ 'paths' => ['api/*', 'sanctum/csrf-cookie'], 'allowed_methods' => ['*'], 'allowed_origins' => ['http://localhost:3000', 'http://localhost:8000'], 'allowed_headers' => ['*'], 'supports_credentials' => true, ];

Step 5: Create Form Request Validation

Generate requests:

php artisan make:request RegisterRequest php artisan make:request LoginRequest

Edit app/Http/Requests/RegisterRequest.php:

public function rules() { return [ 'name' => 'required|string|max:255', 'email' => 'required|email|unique:users,email', 'password' => 'required|confirmed|min:8', ]; }

Edit app/Http/Requests/LoginRequest.php:

public function rules() { return [ 'email' => 'required|email', 'password' => 'required|string', ]; }

Step 6: Create API Auth Controller

Generate controller:

php artisan make:controller API/AuthController

Edit app/Http/Controllers/API/AuthController.php:

<?php namespace App\Http\Controllers\API; use App\Http\Controllers\Controller; use App\Http\Requests\RegisterRequest; use App\Http\Requests\LoginRequest; use App\Http\Resources\UserResource; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; use App\Models\User; class AuthController extends Controller { public function register(RegisterRequest $request) { $user = User::create([ 'name' => $request->name, 'email' => $request->email, 'password' => Hash::make($request->password), ]); Auth::login($user); return new UserResource($user); } public function login(LoginRequest $request) { if (!Auth::attempt($request->only('email', 'password'))) { return response()->json(['message' => 'Invalid credentials'], 401); } return new UserResource(Auth::user()); } public function user() { return new UserResource(Auth::user()); } public function logout() { Auth::logout(); request()->session()->invalidate(); request()->session()->regenerateToken(); return response()->json(['message' => 'Logged out']); } }

Step 7: Create User API Resource

Generate resource:

php artisan make:resource UserResource

Edit app/Http/Resources/UserResource.php:

public function toArray($request) { return [ 'id' => $this->id, 'name' => $this->name, 'email' => $this->email, ]; }

Step 8: Define API Routes

Edit routes/api.php:

use App\Http\Controllers\API\AuthController; Route::post('/register', [AuthController::class, 'register']); Route::post('/login', [AuthController::class, 'login']); Route::middleware('auth:sanctum')->group(function () { Route::get('/user', [AuthController::class, 'user']); Route::post('/logout', [AuthController::class, 'logout']); });

Step 9: Create Vue Project Directories & Files

Run in project root:

mkdir resources/js/router mkdir resources/js/components mkdir resources/js/stores mkdir resources/js/services touch resources/js/router/index.js touch resources/js/stores/auth.js touch resources/js/services/api.js touch resources/js/components/Login.vue touch resources/js/components/Register.vue touch resources/js/components/Dashboard.vue touch resources/js/App.vue

Step 10: Install Vue 3 and Dependencies

npm install vue vue-router@4 pinia npm install --save-dev @vitejs/plugin-vue

Step 11: Configure Vite

Create/Edit vite.config.js:

import { defineConfig } from 'vite'; import laravel from 'laravel-vite-plugin'; import vue from '@vitejs/plugin-vue'; import path from 'path'; export default defineConfig({ plugins: [ laravel({ input: ['resources/js/app.js'], refresh: true, }), vue(), ], resolve: { alias: { '@': path.resolve(__dirname, 'resources/js'), }, }, });

Step 12: Create Vue Project Structure

resources/js/ ├── app.js ├── router/ │ └── index.js ├── stores/ │ └── auth.js ├── services/ │ └── api.js ├── components/ │ ├── Login.vue │ ├── Register.vue │ └── Dashboard.vue └── App.vue

Step 13: Vue Router Setup

resources/js/router/index.js:

import { createRouter, createWebHistory } from 'vue-router'; import Login from '@/components/Login.vue'; import Register from '@/components/Register.vue'; import Dashboard from '@/components/Dashboard.vue'; import { useAuthStore } from '@/stores/auth'; const routes = [ { path: '/login', component: Login, meta: { guest: true } }, { path: '/register', component: Register, meta: { guest: true } }, { path: '/', component: Dashboard, meta: { requiresAuth: true } }, ]; const router = createRouter({ history: createWebHistory(), routes, }); router.beforeEach((to, from, next) => { const auth = useAuthStore(); if (to.meta.requiresAuth && !auth.isLoggedIn) next('/login'); else if (to.meta.guest && auth.isLoggedIn) next('/'); else next(); }); export default router;

Step 14: Pinia Store Setup

resources/js/stores/auth.js:

import { defineStore } from 'pinia'; export const useAuthStore = defineStore('auth', { state: () => ({ user: null, }), getters: { isLoggedIn: (state) => !!state.user, }, actions: { // Normalize API response setUser(response) { this.user = response?.data ?? null; }, async fetchUser() { try { const res = await fetch('/api/user', { credentials: 'include', headers: { Accept: 'application/json' }, }); if (!res.ok) throw new Error(); const data = await res.json(); this.user = data.data; } catch { this.user = null; } }, async logout() { await fetch('/api/logout', { method: 'POST', credentials: 'include', headers: { Accept: 'application/json' }, }); this.user = null; }, }, });

Step 15: API Service Helper

resources/js/services/api.js:

export async function csrf() { await fetch('/sanctum/csrf-cookie', { credentials: 'include' }); } export async function apiRequest(url, options = {}) { await csrf(); const response = await fetch(url, { credentials: 'include', ...options, }); if (!response.ok) { const error = await response.json(); throw new Error(error.message || 'API Error'); } return response.json(); }

Step 16: Vue Entry Point

resources/js/app.js:

import { createApp } from 'vue'; import { createPinia } from 'pinia'; import router from './router'; import App from './App.vue'; const app = createApp(App); app.use(createPinia()); app.use(router); app.mount('#app');

Step 17: Main Vue Component

resources/js/App.vue:

<template> <router-view /> </template>

Step 18: Vue Components

Login.vue

<template> <div class="auth-container"> <h2>Login</h2> <input v-model="email" type="email" placeholder="Email" /> <input v-model="password" type="password" placeholder="Password" /> <button @click="handleLogin" :disabled="loading"> {{ loading ? 'Logging in...' : 'Login' }} </button> <p v-if="message" class="message">{{ message }}</p> <p> Don't have an account? <router-link to="/register">Register here</router-link> </p> </div> </template> <script setup> import { ref } from 'vue'; import { useRouter } from 'vue-router'; import { useAuthStore } from '@/stores/auth'; import { apiRequest } from '@/services/api'; const router = useRouter(); const auth = useAuthStore(); const email = ref(''); const password = ref(''); const message = ref(''); const loading = ref(false); async function handleLogin() { message.value = ''; loading.value = true; try { const user = await apiRequest('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'include', body: JSON.stringify({ email: email.value, password: password.value, }), }); auth.setUser(user); router.push('/'); } catch (error) { message.value = error.message || 'Login failed'; } finally { loading.value = false; } } </script> <style scoped> .auth-container { max-width: 400px; margin: 2rem auto; display: flex; flex-direction: column; gap: 1rem; } input { padding: 0.5rem; font-size: 1rem; } button { padding: 0.6rem; font-size: 1rem; cursor: pointer; } .message { color: red; } </style>

Register.vue

<template> <div class="auth-container"> <h2>Register</h2> <input v-model="name" type="text" placeholder="Name" /> <input v-model="email" type="email" placeholder="Email" /> <input v-model="password" type="password" placeholder="Password" /> <input v-model="passwordConfirm" type="password" placeholder="Confirm Password" /> <button @click="handleRegister" :disabled="loading"> {{ loading ? 'Registering...' : 'Register' }} </button> <p v-if="message" class="message">{{ message }}</p> <p> Already have an account? <router-link to="/login">Login here</router-link> </p> </div> </template> <script setup> import { ref } from 'vue'; import { useRouter } from 'vue-router'; import { useAuthStore } from '@/stores/auth'; import { apiRequest } from '@/services/api'; const router = useRouter(); const auth = useAuthStore(); const name = ref(''); const email = ref(''); const password = ref(''); const passwordConfirm = ref(''); const message = ref(''); const loading = ref(false); async function handleRegister() { message.value = ''; if (password.value !== passwordConfirm.value) { message.value = 'Passwords do not match'; return; } loading.value = true; try { await apiRequest('/api/register', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'include', body: JSON.stringify({ name: name.value, email: email.value, password: password.value, password_confirmation: passwordConfirm.value, }), }); // Redirect to login page automatically on success router.push('/login'); } catch (error) { message.value = error.message || 'Registration failed'; } finally { loading.value = false; } } </script> <style scoped> .auth-container { max-width: 400px; margin: 2rem auto; display: flex; flex-direction: column; gap: 1rem; } input { padding: 0.5rem; font-size: 1rem; } button { padding: 0.6rem; font-size: 1rem; cursor: pointer; } .message { color: red; } </style>

Dashboard.vue

<template> <div class="dashboard-container"> <h2>Welcome, {{ auth.user?.name || 'User' }}</h2> <p>Email: {{ auth.user?.email }}</p> <button @click="handleLogout" :disabled="loading"> {{ loading ? 'Logging out...' : 'Logout' }} </button> </div> </template> <script setup> import { ref } from 'vue'; import { useRouter } from 'vue-router'; import { useAuthStore } from '@/stores/auth'; const router = useRouter(); const auth = useAuthStore(); const loading = ref(false); async function handleLogout() { loading.value = true; try { await auth.logout(); router.push('/login'); } catch (error) { alert('Logout failed.'); } finally { loading.value = false; } } </script> <style scoped> .dashboard-container { max-width: 600px; margin: 2rem auto; text-align: center; } button { padding: 0.6rem 1.2rem; font-size: 1rem; cursor: pointer; margin-top: 1rem; } </style>

Step 19: Blade View to Load Vue SPA

routes/web.php file to make your Laravel app serve the welcome view for any route that doesn’t match existing routes (usually for SPA routing fallback):

<?php use Illuminate\Support\Facades\Route; Route::get('/{any}', function () { return view('welcome'); })->where('any', '.*');

Edit or create resources/views/welcome.blade.php:

<!DOCTYPE html> <html lang="en"> <head> <title>Laravel Vue SPA Auth</title> @vite('resources/js/app.js') </head> <body> <div id="app"></div> </body> </html>

Step 20: Run Your Project

Open two terminals:

  1. Start Laravel server:

php artisan serve
  1. Start frontend development server:

npm run dev

Step 21: Visit Your App

Open browser:

http://localhost:8000

You now have a secure SPA with:

  • Laravel 12 API backend

  • Vue 3 SPA frontend

  • Laravel Sanctum session-based authentication (no manual Sanctum middleware setup needed)

Want the full source code?
Download the complete Project example from my GitHub repo here.
Souy Soeng

Souy Soeng

Hi there 👋, I’m Soeng Souy (StarCode Kh)
-------------------------------------------
🌱 I’m currently creating a sample Laravel and React Vue Livewire
👯 I’m looking to collaborate on open-source PHP & JavaScript projects
💬 Ask me about Laravel, MySQL, or Flutter
⚡ Fun fact: I love turning ☕️ into code!

2 Comments

CAN FEEDBACK
  1. Anonymous
    Anonymous
    interesting
  2. Anonymous
    Anonymous
    When you refresh the page it returns to the Login page? does it not remember if you are logged in?
close