Building Modern Web Apps with Laravel + Vue 3
Learn how to combine Laravel's powerful backend with Vue 3's modern frontend to create full-stack applications
Learn how to combine Laravel's powerful backend with Vue 3's modern frontend to create full-stack applications

Introduction: The landscape of web development has evolved dramatically, with developers seeking solutions that combine robust backend capabilities with modern, reactive frontends. Laravel and Vue 3 represent a perfect marriage of these needs—Laravel's elegant PHP framework handles server-side concerns like routing, authentication, and database operations, while Vue 3's Composition API and reactivity system create dynamic, performant user interfaces. This powerful combination allows you to build full-stack applications with confidence, productivity, and maintainability.
Combining Laravel's robust backend framework with Vue 3's reactive frontend creates a powerful full-stack development experience for modern web applications. Laravel provides a complete backend solution with authentication, database migrations, API routes, and more, while Vue 3 offers a progressive JavaScript framework with excellent reactivity, component composition, and TypeScript support. Together, they form a cohesive ecosystem that streamlines full-stack development.
Understanding the strengths of this stack helps you leverage its full potential.
Laravel is one of the most popular PHP frameworks, known for its elegant syntax and developer-friendly features:
Robust Architecture: MVC pattern, dependency injection, and service containers provide a solid foundation for scalable applications.
Built-in Authentication: Laravel Breeze, Jetstream, and Sanctum make authentication and API token management straightforward.
Eloquent ORM: Intuitive database interactions with powerful query building and relationship management.
API Development: Built-in API resources, rate limiting, and RESTful routing conventions.
Rich Ecosystem: Queue management, file storage, email sending, and more—all included out of the box.
Vue 3 brings modern JavaScript features and improved performance:
Composition API: Better code organization, reusability, and TypeScript support compared to Options API.
Reactivity System: Powered by Proxies for better performance and more intuitive reactive programming.
Performance: Smaller bundle size, faster rendering, and improved memory efficiency.
Developer Experience: Excellent tooling with Vite, comprehensive documentation, and a vibrant ecosystem.
Progressive Framework: Start small and scale up—use as much or as little as you need.
Single Page Applications: Laravel handles API endpoints while Vue manages the frontend, creating smooth, app-like experiences.
SEO-Friendly Options: Use Laravel's Blade templates for initial render with Vue for interactivity, or combine with Inertia.js for SPA-like feel with server-side routing.
Unified Development: JavaScript for frontend, PHP for backend, but seamless data flow and shared conventions.
Full-Stack Productivity: One repository, one deployment, cohesive development workflow.
Let's create a new Laravel project with Vue 3 integration.
# Using Composer
composer create-project laravel/laravel laravel-vue-app
# Navigate to project
cd laravel-vue-app
# Install Laravel dependencies
composer install
# Copy environment file
cp .env.example .env
# Generate application key
php artisan key:generate
# Configure database in .env
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=laravel_vue
DB_USERNAME=root
DB_PASSWORD=
# Run migrations
php artisan migrate
# Install Vue 3 and related packages
npm install vue@next @vitejs/plugin-vue
# Install additional dependencies
npm install vue-router@4 pinia axios
npm install -D @vue/compiler-sfc
# Terminal 1: Laravel server
php artisan serve
# Terminal 2: Vite dev server
npm run dev
Laravel uses Vite for asset bundling. Let's configure it for Vue 3.
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [
laravel({
input: ['resources/css/app.css', 'resources/js/app.js'],
refresh: true,
}),
vue({
template: {
transformAssetUrls: {
base: null,
includeAbsolute: false,
},
},
}),
],
resolve: {
alias: {
'@': '/resources/js',
},
},
});
// resources/js/app.js
import './bootstrap';
import { createApp } from 'vue';
import App from './components/App.vue';
import router from './router';
import { createPinia } from 'pinia';
const app = createApp(App);
app.use(router);
app.use(createPinia());
app.mount('#app');
<!-- resources/views/app.blade.php -->
<!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 Vue App</title>
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body>
<div id="app"></div>
</body>
</html>
// routes/web.php
use Illuminate\Support\Facades\Route;
Route::get('/{any}', function () {
return view('app');
})->where('any', '.*');
Let's create a Vue 3 component using the Composition API.
<!-- resources/js/components/App.vue -->
<template>
<div id="app">
<Header />
<main class="container mx-auto px-4 py-8">
<router-view />
</main>
<Footer />
</div>
</template>
<script setup>
import Header from './layout/Header.vue';
import Footer from './layout/Footer.vue';
</script>
<style scoped>
#app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
main {
flex: 1;
}
</style>
<!-- resources/js/components/layout/Header.vue -->
<template>
<header class="bg-blue-600 text-white shadow-lg">
<nav class="container mx-auto px-4 py-4">
<div class="flex items-center justify-between">
<router-link to="/" class="text-2xl font-bold">
Laravel + Vue 3
</router-link>
<ul class="flex space-x-6">
<li>
<router-link to="/" class="hover:text-blue-200">
Home
</router-link>
</li>
<li>
<router-link to="/users" class="hover:text-blue-200">
Users
</router-link>
</li>
<li>
<router-link to="/about" class="hover:text-blue-200">
About
</router-link>
</li>
</ul>
</div>
</nav>
</header>
</template>
<script setup>
// Component logic here
</script>
<!-- resources/js/components/pages/Home.vue -->
<template>
<div class="home">
<h1 class="text-4xl font-bold mb-4">Welcome to Laravel + Vue 3</h1>
<p class="text-gray-600 mb-8">
Building modern web applications with Laravel backend and Vue 3 frontend.
</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="card" v-for="feature in features" :key="feature.id">
<h3 class="text-xl font-semibold mb-2">{{ feature.title }}</h3>
<p class="text-gray-600">{{ feature.description }}</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
const features = ref([
{
id: 1,
title: 'Powerful Backend',
description: 'Laravel provides robust API development with authentication, validation, and ORM.'
},
{
id: 2,
title: 'Reactive Frontend',
description: 'Vue 3 Composition API offers better code organization and TypeScript support.'
},
{
id: 3,
title: 'Seamless Integration',
description: 'Vite enables fast development with hot module replacement and optimized builds.'
}
]);
</script>
<style scoped>
.card {
padding: 1.5rem;
border-radius: 0.5rem;
background: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
</style>
Set up client-side routing with Vue Router 4.
// resources/js/router/index.js
import { createRouter, createWebHistory } from 'vue-router';
const routes = [
{
path: '/',
name: 'Home',
component: () => import('@/components/pages/Home.vue')
},
{
path: '/users',
name: 'Users',
component: () => import('@/components/pages/Users.vue')
},
{
path: '/users/:id',
name: 'UserDetail',
component: () => import('@/components/pages/UserDetail.vue')
},
{
path: '/about',
name: 'About',
component: () => import('@/components/pages/About.vue')
},
{
path: '/login',
name: 'Login',
component: () => import('@/components/auth/Login.vue'),
meta: { guest: true }
},
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/components/pages/Dashboard.vue'),
meta: { requiresAuth: true }
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/components/pages/NotFound.vue')
}
];
const router = createRouter({
history: createWebHistory(),
routes
});
// Navigation guards
router.beforeEach((to, from, next) => {
const isAuthenticated = localStorage.getItem('token');
if (to.meta.requiresAuth && !isAuthenticated) {
next({ name: 'Login' });
} else if (to.meta.guest && isAuthenticated) {
next({ name: 'Dashboard' });
} else {
next();
}
});
export default router;
<template>
<div>
<!-- Programmatic navigation -->
<button @click="goToUser(123)">View User</button>
<!-- Router link -->
<router-link :to="{ name: 'UserDetail', params: { id: 123 } }">
View User
</router-link>
</div>
</template>
<script setup>
import { useRouter, useRoute } from 'vue-router';
const router = useRouter();
const route = useRoute();
function goToUser(id) {
router.push({ name: 'UserDetail', params: { id } });
}
// Access route params
const userId = route.params.id;
</script>
Manage application state with Pinia, Vue 3's recommended state management solution.
// resources/js/stores/userStore.js
import { defineStore } from 'pinia';
import axios from 'axios';
export const useUserStore = defineStore('user', {
state: () => ({
users: [],
currentUser: null,
loading: false,
error: null
}),
getters: {
getUserById: (state) => (id) => {
return state.users.find(user => user.id === id);
},
activeUsers: (state) => {
return state.users.filter(user => user.active);
}
},
actions: {
async fetchUsers() {
this.loading = true;
this.error = null;
try {
const response = await axios.get('/api/users');
this.users = response.data.data;
} catch (error) {
this.error = error.message;
console.error('Error fetching users:', error);
} finally {
this.loading = false;
}
},
async fetchUser(id) {
this.loading = true;
this.error = null;
try {
const response = await axios.get(`/api/users/${id}`);
this.currentUser = response.data.data;
} catch (error) {
this.error = error.message;
} finally {
this.loading = false;
}
},
async createUser(userData) {
try {
const response = await axios.post('/api/users', userData);
this.users.push(response.data.data);
return response.data.data;
} catch (error) {
this.error = error.message;
throw error;
}
},
async updateUser(id, userData) {
try {
const response = await axios.put(`/api/users/${id}`, userData);
const index = this.users.findIndex(u => u.id === id);
if (index !== -1) {
this.users[index] = response.data.data;
}
return response.data.data;
} catch (error) {
this.error = error.message;
throw error;
}
},
async deleteUser(id) {
try {
await axios.delete(`/api/users/${id}`);
this.users = this.users.filter(u => u.id !== id);
} catch (error) {
this.error = error.message;
throw error;
}
}
}
});
<!-- resources/js/components/pages/Users.vue -->
<template>
<div>
<h1 class="text-3xl font-bold mb-6">Users</h1>
<div v-if="loading" class="text-center">
<p>Loading users...</p>
</div>
<div v-else-if="error" class="text-red-500">
{{ error }}
</div>
<div v-else>
<button
@click="showCreateModal = true"
class="bg-blue-500 text-white px-4 py-2 rounded mb-4"
>
Add User
</button>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="user in users"
:key="user.id"
class="bg-white p-4 rounded shadow"
>
<h3 class="font-semibold">{{ user.name }}</h3>
<p class="text-gray-600">{{ user.email }}</p>
<div class="mt-4 space-x-2">
<button
@click="editUser(user)"
class="text-blue-500"
>
Edit
</button>
<button
@click="deleteUser(user.id)"
class="text-red-500"
>
Delete
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useUserStore } from '@/stores/userStore';
import { storeToRefs } from 'pinia';
const userStore = useUserStore();
const { users, loading, error } = storeToRefs(userStore);
const showCreateModal = ref(false);
onMounted(() => {
userStore.fetchUsers();
});
function editUser(user) {
// Edit logic
}
async function deleteUser(id) {
if (confirm('Are you sure?')) {
await userStore.deleteUser(id);
}
}
</script>
Build reusable composables for API interactions.
// resources/js/bootstrap.js
import axios from 'axios';
window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
window.axios.defaults.baseURL = '/api';
// Add token to requests
const token = localStorage.getItem('token');
if (token) {
window.axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}
// Response interceptor
window.axios.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401) {
localStorage.removeItem('token');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
// resources/js/composables/useApi.js
import { ref } from 'vue';
import axios from 'axios';
export function useApi(url) {
const data = ref(null);
const loading = ref(false);
const error = ref(null);
async function get(params = {}) {
loading.value = true;
error.value = null;
try {
const response = await axios.get(url, { params });
data.value = response.data;
return response.data;
} catch (err) {
error.value = err.message;
throw err;
} finally {
loading.value = false;
}
}
async function post(payload) {
loading.value = true;
error.value = null;
try {
const response = await axios.post(url, payload);
data.value = response.data;
return response.data;
} catch (err) {
error.value = err.message;
throw err;
} finally {
loading.value = false;
}
}
async function put(id, payload) {
loading.value = true;
error.value = null;
try {
const response = await axios.put(`${url}/${id}`, payload);
data.value = response.data;
return response.data;
} catch (err) {
error.value = err.message;
throw err;
} finally {
loading.value = false;
}
}
async function destroy(id) {
loading.value = true;
error.value = null;
try {
await axios.delete(`${url}/${id}`);
return true;
} catch (err) {
error.value = err.message;
throw err;
} finally {
loading.value = false;
}
}
return {
data,
loading,
error,
get,
post,
put,
destroy
};
}
<template>
<div>
<div v-if="loading">Loading...</div>
<div v-else-if="error">{{ error }}</div>
<div v-else>
<div v-for="user in users" :key="user.id">
{{ user.name }}
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useApi } from '@/composables/useApi';
const { data: users, loading, error, get } = useApi('/users');
onMounted(async () => {
await get();
});
</script>
Implement authentication using Laravel Sanctum.
composer require laravel/sanctum
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
php artisan migrate
// app/Http/Kernel.php
'api' => [
\Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
'throttle:api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
// app/Http/Controllers/AuthController.php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
class AuthController extends Controller
{
public function register(Request $request)
{
$request->validate([
'name' => 'required|string|max:255',
'email' => 'required|string|email|max:255|unique:users',
'password' => 'required|string|min:8|confirmed',
]);
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
$token = $user->createToken('auth-token')->plainTextToken;
return response()->json([
'user' => $user,
'token' => $token,
], 201);
}
public function login(Request $request)
{
$request->validate([
'email' => 'required|email',
'password' => 'required',
]);
$user = User::where('email', $request->email)->first();
if (!$user || !Hash::check($request->password, $user->password)) {
throw ValidationException::withMessages([
'email' => ['The provided credentials are incorrect.'],
]);
}
$token = $user->createToken('auth-token')->plainTextToken;
return response()->json([
'user' => $user,
'token' => $token,
]);
}
public function logout(Request $request)
{
$request->user()->currentAccessToken()->delete();
return response()->json(['message' => 'Logged out successfully']);
}
public function user(Request $request)
{
return response()->json($request->user());
}
}
// routes/api.php
use App\Http\Controllers\AuthController;
Route::post('/register', [AuthController::class, 'register']);
Route::post('/login', [AuthController::class, 'login']);
Route::middleware('auth:sanctum')->group(function () {
Route::post('/logout', [AuthController::class, 'logout']);
Route::get('/user', [AuthController::class, 'user']);
});
// resources/js/stores/authStore.js
import { defineStore } from 'pinia';
import axios from 'axios';
export const useAuthStore = defineStore('auth', {
state: () => ({
user: null,
token: localStorage.getItem('token') || null,
loading: false,
error: null
}),
getters: {
isAuthenticated: (state) => !!state.token,
currentUser: (state) => state.user
},
actions: {
async login(credentials) {
this.loading = true;
this.error = null;
try {
const response = await axios.post('/login', credentials);
this.token = response.data.token;
this.user = response.data.user;
localStorage.setItem('token', this.token);
axios.defaults.headers.common['Authorization'] = `Bearer ${this.token}`;
return response.data;
} catch (error) {
this.error = error.response?.data?.message || 'Login failed';
throw error;
} finally {
this.loading = false;
}
},
async register(userData) {
this.loading = true;
this.error = null;
try {
const response = await axios.post('/register', userData);
this.token = response.data.token;
this.user = response.data.user;
localStorage.setItem('token', this.token);
axios.defaults.headers.common['Authorization'] = `Bearer ${this.token}`;
return response.data;
} catch (error) {
this.error = error.response?.data?.message || 'Registration failed';
throw error;
} finally {
this.loading = false;
}
},
async logout() {
try {
await axios.post('/logout');
} catch (error) {
console.error('Logout error:', error);
} finally {
this.token = null;
this.user = null;
localStorage.removeItem('token');
delete axios.defaults.headers.common['Authorization'];
}
},
async fetchUser() {
if (!this.token) return;
try {
const response = await axios.get('/user');
this.user = response.data;
} catch (error) {
this.logout();
}
}
}
});
<!-- resources/js/components/auth/Login.vue -->
<template>
<div class="max-w-md mx-auto mt-8">
<h2 class="text-2xl font-bold mb-4">Login</h2>
<form @submit.prevent="handleLogin" class="space-y-4">
<div>
<label for="email" class="block mb-1">Email</label>
<input
v-model="form.email"
type="email"
id="email"
required
class="w-full border rounded px-3 py-2"
>
</div>
<div>
<label for="password" class="block mb-1">Password</label>
<input
v-model="form.password"
type="password"
id="password"
required
class="w-full border rounded px-3 py-2"
>
</div>
<div v-if="error" class="text-red-500">
{{ error }}
</div>
<button
type="submit"
:disabled="loading"
class="w-full bg-blue-500 text-white py-2 rounded hover:bg-blue-600 disabled:opacity-50"
>
{{ loading ? 'Logging in...' : 'Login' }}
</button>
</form>
</div>
</template>
<script setup>
import { reactive } from 'vue';
import { useAuthStore } from '@/stores/authStore';
import { useRouter } from 'vue-router';
import { storeToRefs } from 'pinia';
const authStore = useAuthStore();
const { loading, error } = storeToRefs(authStore);
const router = useRouter();
const form = reactive({
email: '',
password: ''
});
async function handleLogin() {
try {
await authStore.login(form);
router.push({ name: 'Dashboard' });
} catch (err) {
console.error('Login failed:', err);
}
}
</script>
Handle forms with validation on both client and server sides.
// app/Http/Requests/StoreUserRequest.php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreUserRequest extends FormRequest
{
public function authorize()
{
return true;
}
public function rules()
{
return [
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email',
'age' => 'required|integer|min:18|max:120',
'role' => 'required|in:user,admin,moderator',
];
}
public function messages()
{
return [
'name.required' => 'Please provide a name',
'email.required' => 'Email address is required',
'email.email' => 'Please provide a valid email address',
'age.min' => 'You must be at least 18 years old',
];
}
}
<!-- resources/js/components/forms/UserForm.vue -->
<template>
<form @submit.prevent="handleSubmit" class="max-w-lg">
<div class="mb-4">
<label for="name" class="block mb-1 font-medium">Name</label>
<input
v-model="form.name"
@blur="validateField('name')"
type="text"
id="name"
class="w-full border rounded px-3 py-2"
:class="{ 'border-red-500': errors.name }"
>
<span v-if="errors.name" class="text-red-500 text-sm">
{{ errors.name }}
</span>
</div>
<div class="mb-4">
<label for="email" class="block mb-1 font-medium">Email</label>
<input
v-model="form.email"
@blur="validateField('email')"
type="email"
id="email"
class="w-full border rounded px-3 py-2"
:class="{ 'border-red-500': errors.email }"
>
<span v-if="errors.email" class="text-red-500 text-sm">
{{ errors.email }}
</span>
</div>
<div class="mb-4">
<label for="age" class="block mb-1 font-medium">Age</label>
<input
v-model.number="form.age"
@blur="validateField('age')"
type="number"
id="age"
class="w-full border rounded px-3 py-2"
:class="{ 'border-red-500': errors.age }"
>
<span v-if="errors.age" class="text-red-500 text-sm">
{{ errors.age }}
</span>
</div>
<div class="mb-4">
<label for="role" class="block mb-1 font-medium">Role</label>
<select
v-model="form.role"
id="role"
class="w-full border rounded px-3 py-2"
>
<option value="">Select role</option>
<option value="user">User</option>
<option value="admin">Admin</option>
<option value="moderator">Moderator</option>
</select>
<span v-if="errors.role" class="text-red-500 text-sm">
{{ errors.role }}
</span>
</div>
<button
type="submit"
:disabled="loading || !isFormValid"
class="bg-blue-500 text-white px-6 py-2 rounded hover:bg-blue-600 disabled:opacity-50"
>
{{ loading ? 'Submitting...' : 'Submit' }}
</button>
</form>
</template>
<script setup>
import { reactive, ref, computed } from 'vue';
import axios from 'axios';
const emit = defineEmits(['success']);
const form = reactive({
name: '',
email: '',
age: null,
role: ''
});
const errors = reactive({
name: '',
email: '',
age: '',
role: ''
});
const loading = ref(false);
const isFormValid = computed(() => {
return form.name && form.email && form.age && form.role &&
!errors.name && !errors.email && !errors.age && !errors.role;
});
function validateField(field) {
errors[field] = '';
if (field === 'name' && !form.name) {
errors.name = 'Name is required';
}
if (field === 'email') {
if (!form.email) {
errors.email = 'Email is required';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) {
errors.email = 'Please provide a valid email';
}
}
if (field === 'age') {
if (!form.age) {
errors.age = 'Age is required';
} else if (form.age < 18) {
errors.age = 'You must be at least 18 years old';
}
}
}
async function handleSubmit() {
// Validate all fields
Object.keys(form).forEach(validateField);
if (!isFormValid.value) return;
loading.value = true;
try {
const response = await axios.post('/users', form);
emit('success', response.data);
// Reset form
Object.keys(form).forEach(key => {
form[key] = '';
});
} catch (error) {
if (error.response?.data?.errors) {
// Handle Laravel validation errors
Object.keys(error.response.data.errors).forEach(key => {
errors[key] = error.response.data.errors[key][0];
});
}
} finally {
loading.value = false;
}
}
</script>
Add real-time functionality using Laravel Echo and Pusher.
# Backend
composer require pusher/pusher-php-server
# Frontend
npm install --save-dev laravel-echo pusher-js
// config/broadcasting.php
'pusher' => [
'driver' => 'pusher',
'key' => env('PUSHER_APP_KEY'),
'secret' => env('PUSHER_APP_SECRET'),
'app_id' => env('PUSHER_APP_ID'),
'options' => [
'cluster' => env('PUSHER_APP_CLUSTER'),
'useTLS' => true,
],
],
// app/Events/MessageSent.php
namespace App\Events;
use App\Models\Message;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class MessageSent implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public $message;
public function __construct(Message $message)
{
$this->message = $message;
}
public function broadcastOn()
{
return new Channel('chat');
}
}
// resources/js/bootstrap.js
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';
window.Pusher = Pusher;
window.Echo = new Echo({
broadcaster: 'pusher',
key: import.meta.env.VITE_PUSHER_APP_KEY,
cluster: import.meta.env.VITE_PUSHER_APP_CLUSTER,
forceTLS: true
});
<template>
<div class="chat">
<div class="messages" ref="messagesContainer">
<div
v-for="message in messages"
:key="message.id"
class="message"
>
<strong>{{ message.user.name }}:</strong>
{{ message.content }}
</div>
</div>
<form @submit.prevent="sendMessage">
<input
v-model="newMessage"
type="text"
placeholder="Type a message..."
class="w-full border rounded px-3 py-2"
>
</form>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, nextTick } from 'vue';
import axios from 'axios';
const messages = ref([]);
const newMessage = ref('');
const messagesContainer = ref(null);
onMounted(() => {
fetchMessages();
// Listen for new messages
window.Echo.channel('chat')
.listen('MessageSent', (e) => {
messages.value.push(e.message);
scrollToBottom();
});
});
onUnmounted(() => {
window.Echo.leaveChannel('chat');
});
async function fetchMessages() {
const response = await axios.get('/messages');
messages.value = response.data;
await nextTick();
scrollToBottom();
}
async function sendMessage() {
if (!newMessage.value.trim()) return;
await axios.post('/messages', {
content: newMessage.value
});
newMessage.value = '';
}
function scrollToBottom() {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
}
}
</script>
Let's build a complete task management application.
// app/Models/Task.php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Task extends Model
{
use HasFactory;
protected $fillable = ['title', 'description', 'completed', 'user_id'];
protected $casts = [
'completed' => 'boolean',
];
public function user()
{
return $this->belongsTo(User::class);
}
}
// app/Http/Controllers/TaskController.php
namespace App\Http\Controllers;
use App\Models\Task;
use Illuminate\Http\Request;
class TaskController extends Controller
{
public function index()
{
return Task::with('user')
->where('user_id', auth()->id())
->latest()
->get();
}
public function store(Request $request)
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'description' => 'nullable|string',
]);
$task = auth()->user()->tasks()->create($validated);
return response()->json($task, 201);
}
public function update(Request $request, Task $task)
{
$this->authorize('update', $task);
$validated = $request->validate([
'title' => 'sometimes|string|max:255',
'description' => 'nullable|string',
'completed' => 'sometimes|boolean',
]);
$task->update($validated);
return response()->json($task);
}
public function destroy(Task $task)
{
$this->authorize('delete', $task);
$task->delete();
return response()->json(null, 204);
}
}
<!-- resources/js/components/pages/Tasks.vue -->
<template>
<div class="max-w-4xl mx-auto">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold">My Tasks</h1>
<button
@click="showCreateModal = true"
class="bg-blue-500 text-white px-4 py-2 rounded"
>
Add Task
</button>
</div>
<div class="space-y-4">
<div
v-for="task in tasks"
:key="task.id"
class="bg-white p-4 rounded shadow flex items-start justify-between"
>
<div class="flex items-start space-x-3 flex-1">
<input
type="checkbox"
:checked="task.completed"
@change="toggleTask(task)"
class="mt-1"
>
<div class="flex-1">
<h3
class="font-semibold"
:class="{ 'line-through text-gray-500': task.completed }"
>
{{ task.title }}
</h3>
<p class="text-gray-600 text-sm">{{ task.description }}</p>
</div>
</div>
<div class="flex space-x-2">
<button
@click="editTask(task)"
class="text-blue-500 hover:text-blue-700"
>
Edit
</button>
<button
@click="deleteTask(task.id)"
class="text-red-500 hover:text-red-700"
>
Delete
</button>
</div>
</div>
</div>
<!-- Create/Edit Modal -->
<Modal v-model="showCreateModal" title="Add Task">
<TaskForm
:task="selectedTask"
@success="handleFormSuccess"
@cancel="closeModal"
/>
</Modal>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import axios from 'axios';
import TaskForm from '@/components/forms/TaskForm.vue';
import Modal from '@/components/ui/Modal.vue';
const tasks = ref([]);
const showCreateModal = ref(false);
const selectedTask = ref(null);
onMounted(() => {
fetchTasks();
});
async function fetchTasks() {
const response = await axios.get('/tasks');
tasks.value = response.data;
}
async function toggleTask(task) {
await axios.put(`/tasks/${task.id}`, {
completed: !task.completed
});
await fetchTasks();
}
function editTask(task) {
selectedTask.value = task;
showCreateModal.value = true;
}
async function deleteTask(id) {
if (!confirm('Are you sure?')) return;
await axios.delete(`/tasks/${id}`);
await fetchTasks();
}
function handleFormSuccess() {
closeModal();
fetchTasks();
}
function closeModal() {
showCreateModal.value = false;
selectedTask.value = null;
}
</script>
Add TypeScript for type safety.
npm install -D typescript @types/node
npm install -D @vue/tsconfig
// tsconfig.json
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./resources/js/*"]
},
"types": ["vite/client"]
},
"include": ["resources/js/**/*"],
"exclude": ["node_modules"]
}
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import axios from 'axios';
interface User {
id: number;
name: string;
email: string;
created_at: string;
}
const users = ref<User[]>([]);
const loading = ref<boolean>(false);
async function fetchUsers(): Promise<void> {
loading.value = true;
try {
const response = await axios.get<User[]>('/api/users');
users.value = response.data;
} finally {
loading.value = false;
}
}
onMounted(() => {
fetchUsers();
});
</script>
Write tests for both Laravel and Vue.
// tests/Feature/TaskTest.php
namespace Tests\Feature;
use App\Models\Task;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class TaskTest extends TestCase
{
use RefreshDatabase;
public function test_user_can_create_task()
{
$user = User::factory()->create();
$response = $this->actingAs($user)->postJson('/api/tasks', [
'title' => 'Test Task',
'description' => 'Test Description',
]);
$response->assertStatus(201);
$this->assertDatabaseHas('tasks', [
'title' => 'Test Task',
'user_id' => $user->id,
]);
}
public function test_user_can_only_see_own_tasks()
{
$user1 = User::factory()->create();
$user2 = User::factory()->create();
Task::factory()->create(['user_id' => $user1->id]);
Task::factory()->create(['user_id' => $user2->id]);
$response = $this->actingAs($user1)->getJson('/api/tasks');
$response->assertJsonCount(1);
}
}
npm install -D @vue/test-utils vitest jsdom
// resources/js/components/__tests__/TaskList.spec.js
import { describe, it, expect, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import TaskList from '../TaskList.vue';
describe('TaskList', () => {
it('renders tasks', () => {
const tasks = [
{ id: 1, title: 'Task 1', completed: false },
{ id: 2, title: 'Task 2', completed: true },
];
const wrapper = mount(TaskList, {
props: { tasks }
});
expect(wrapper.findAll('.task')).toHaveLength(2);
});
it('emits toggle event when checkbox clicked', async () => {
const tasks = [{ id: 1, title: 'Task 1', completed: false }];
const wrapper = mount(TaskList, {
props: { tasks }
});
await wrapper.find('input[type="checkbox"]').trigger('change');
expect(wrapper.emitted('toggle')).toBeTruthy();
expect(wrapper.emitted('toggle')[0]).toEqual([tasks[0]]);
});
});
Prepare your application for production.
# Production .env
APP_ENV=production
APP_DEBUG=false
APP_URL=https://yourdomain.com
# Database
DB_CONNECTION=mysql
DB_HOST=your-db-host
DB_DATABASE=production_db
# Cache
CACHE_DRIVER=redis
SESSION_DRIVER=redis
QUEUE_CONNECTION=redis
# Install dependencies
composer install --optimize-autoloader --no-dev
# Optimize Laravel
php artisan config:cache
php artisan route:cache
php artisan view:cache
# Build frontend
npm run build
server {
listen 80;
server_name yourdomain.com;
root /var/www/html/public;
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
index index.php;
charset utf-8;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
error_page 404 /index.php;
location ~ \.php$ {
fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
location ~ /\.(?!well-known).* {
deny all;
}
}
Building modern web applications with Laravel and Vue 3 provides a powerful, flexible, and enjoyable development experience. Laravel handles the backend complexities with elegance, while Vue 3's Composition API brings reactivity and component-based architecture to your frontend.
This combination allows you to build anything from simple CRUD applications to complex, real-time platforms. The strong ecosystems around both frameworks provide solutions for authentication, state management, routing, testing, and deployment, enabling you to focus on building features rather than infrastructure.
The future of web development is here, and Laravel + Vue 3 gives you all the tools you need to build amazing applications. Start small, learn continuously, and enjoy the journey of creating modern web experiences!