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!

Post a Comment

CAN FEEDBACK
close