Full Laravel 12 + Vue 3 SPA Authentication Tutorial Using Passport

Introduction

Build a secure SPA using Laravel 12 backend API with Passport OAuth2 authentication and Vue 3 frontend in a single project.

Unlike Sanctum (cookie-based), this uses API tokens (Bearer tokens) and OAuth2 flow.

Why Laravel Passport?

✅ Industry-standard OAuth2 API authentication
✅ Token-based (mobile apps, third-party APIs ready)
✅ Secure access tokens and refresh tokens
✅ Works well with SPA and mobile clients
✅ Full OAuth2 server implementation

What You Will Build

  • User Registration & Login via API (Passport)

  • Authenticated User Profile

  • Logout (token revocation)

  • Protected API routes with middleware

  • Vue 3 SPA frontend with Vue Router + Pinia

  • Laravel 12 API backend with Passport

Prerequisites

  • PHP 8.1+

  • Composer

  • Node.js & npm

  • MySQL

  • Basic Laravel & Vue knowledge

Step 1: Create Laravel Project

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

Step 2: Setup Database

Create MySQL database laravel_vue_passport.

Edit .env:

DB_DATABASE=laravel_vue_passport DB_USERNAME=root DB_PASSWORD=your_password_here

Step 3: Install Laravel Passport

composer require laravel/passport

Publish Passport migrations and config:

php artisan passport:install
php artisan install:api
php artisan migrate

Step 4: Configure User Model for Passport

Edit app/Models/User.php:

<?php namespace App\Models; use Laravel\Passport\HasApiTokens; // add this use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; class User extends Authenticatable { use HasApiTokens, Notifiable; // add HasApiTokens here // ... your existing code ... }

Step 5: Configure Auth Config

Edit config/auth.php:

'guards' => [ 'web' => [ 'driver' => 'session', 'provider' => 'users', ], 'api' => [ 'driver' => 'passport', // use passport driver here 'provider' => 'users', 'hash' => false, ], ],

Step 6: Setup API Auth Controller

Generate:

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; use Laravel\Passport\RefreshTokenRepository; use Laravel\Passport\TokenRepository; use Illuminate\Http\Request; class AuthController extends Controller { // Register new user and return token public function register(RegisterRequest $request) { $user = User::create([ 'name' => $request->name, 'email' => $request->email, 'password' => Hash::make($request->password), ]); $token = $user->createToken('API Token')->accessToken; return response()->json([ 'user' => new UserResource($user), 'token' => $token, ]); } // Login user and return token public function login(LoginRequest $request) { $credentials = $request->only('email', 'password'); if (!Auth::attempt($credentials)) { return response()->json(['message' => 'Invalid credentials'], 401); } $user = Auth::user(); $token = $user->createToken('API Token')->accessToken; return response()->json([ 'user' => new UserResource($user), 'token' => $token, ]); } // Return authenticated user info public function user(Request $request) { return new UserResource($request->user()); } // Logout (revoke token) public function logout(Request $request) { $token = $request->user()->token(); $token->revoke(); // Revoke refresh tokens too (optional) app(RefreshTokenRepository::class)->revokeRefreshTokensByAccessTokenId($token->id); return response()->json(['message' => 'Logged out']); } }

Step 7: Create Form Request Validations

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 8: Create User API 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 9: 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:api')->group(function () { Route::get('/user', [AuthController::class, 'user']); Route::post('/logout', [AuthController::class, 'logout']); });

Step 10: Create Vue Project Directories & Files

mkdir -p resources/js/router mkdir -p resources/js/components mkdir -p resources/js/stores mkdir -p resources/js/services touch resources/js/app.js touch resources/js/App.vue 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

Step 11: Install Vue 3 Dependencies

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

Step 12: Configure Vite

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 13: Vue Router Setup

Edit 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

Edit resources/js/stores/auth.js:

import { defineStore } from 'pinia'; export const useAuthStore = defineStore('auth', { state: () => ({ user: null, token: localStorage.getItem('token') || null, }), getters: { isLoggedIn: (state) => !!state.user && !!state.token, }, actions: { setUser(response) { this.user = response.user ?? null; this.token = response.token ?? null; if (this.token) { localStorage.setItem('token', this.token); } else { localStorage.removeItem('token'); } }, async fetchUser() { if (!this.token) { this.user = null; return; } try { const res = await fetch('/api/user', { headers: { 'Accept': 'application/json', 'Authorization': `Bearer ${this.token}`, }, }); if (!res.ok) throw new Error(); const data = await res.json(); this.user = data.data; } catch { this.user = null; this.token = null; localStorage.removeItem('token'); } }, async logout() { if (!this.token) return; await fetch('/api/logout', { method: 'POST', headers: { 'Accept': 'application/json', 'Authorization': `Bearer ${this.token}`, }, }); this.user = null; this.token = null; localStorage.removeItem('token'); }, }, });

Step 15: API Service Helper

Edit resources/js/services/api.js:

export async function apiRequest(url, options = {}) { const token = localStorage.getItem('token'); const headers = options.headers || {}; if (token) { headers['Authorization'] = `Bearer ${token}`; } headers['Accept'] = 'application/json'; const response = await fetch(url, { ...options, headers, }); if (!response.ok) { const error = await response.json(); throw new Error(error.message || 'API Error'); } return response.json(); }

Step 16: Vue Entry Point

Edit 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

Edit resources/js/App.vue:

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

Step 18: Vue Components (Login, Register, Dashboard)

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' }, 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' }, body: JSON.stringify({ name: name.value, email: email.value, password: password.value, password_confirmation: passwordConfirm.value, }), }); 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

Edit routes/web.php:

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

Create resources/views/welcome.blade.php:

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

Step 20: Run Your Project

Open two terminals:

Start Laravel backend:

php artisan serve

Start frontend dev 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 using Passport OAuth2

  • Vue 3 SPA frontend with Vue Router + Pinia

  • API token-based authentication with Bearer tokens

  • Login, registration, user profile, logout, protected API routes

Summary

This tutorial uses Passport instead of Sanctum for API authentication. You get Bearer tokens stored in localStorage and sent via headers.

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