Back to Posts
Development #Laravel #Vue.js #JavaScript #PHP #Full-stack

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

8 min read
Building Modern Web Apps with Laravel + Vue 3

Building Modern Web Apps with Laravel + Vue 3

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.


Table of Contents

  1. Why Laravel + Vue 3?
  2. Project Setup and Installation
  3. Configuring Vite for Vue 3
  4. Building Your First Component
  5. Vue Router Integration
  6. State Management with Pinia
  7. API Integration with Composition API
  8. Authentication and Authorization
  9. Form Handling and Validation
  10. Real-time Features with Laravel Echo
  11. Building a Complete CRUD Application
  12. TypeScript Integration
  13. Testing Your Application
  14. Deployment Best Practices

Why Laravel + Vue 3?

Understanding the strengths of this stack helps you leverage its full potential.

Laravel's Backend Excellence

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's Frontend Power

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.

The Perfect Match

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.


Project Setup and Installation

Let's create a new Laravel project with Vue 3 integration.

Install Laravel

# Using Composer
composer create-project laravel/laravel laravel-vue-app

# Navigate to project
cd laravel-vue-app

# Install Laravel dependencies
composer install

Configure Environment

# 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 Frontend Dependencies

# 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

Start Development Servers

# Terminal 1: Laravel server
php artisan serve

# Terminal 2: Vite dev server
npm run dev

Configuring Vite for Vue 3

Laravel uses Vite for asset bundling. Let's configure it for Vue 3.

Update vite.config.js

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

Create App Entry Point

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

Update Blade Template

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

Configure Routes

// routes/web.php
use Illuminate\Support\Facades\Route;

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

Building Your First Component

Let's create a Vue 3 component using the Composition API.

Root Component

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

Header Component

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

Home Component

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

Vue Router Integration

Set up client-side routing with Vue Router 4.

Router Configuration

// 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;

Using Router in Components

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

State Management with Pinia

Manage application state with Pinia, Vue 3's recommended state management solution.

Create User Store

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

Using Store in Components

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

API Integration with Composition API

Build reusable composables for API interactions.

Configure Axios

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

Create API Composable

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

Using the Composable

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

Authentication and Authorization

Implement authentication using Laravel Sanctum.

Install Laravel Sanctum

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

Configure Sanctum

// app/Http/Kernel.php
'api' => [
    \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
    'throttle:api',
    \Illuminate\Routing\Middleware\SubstituteBindings::class,
],

Create Auth Controller

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

Auth Routes

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

Auth Store

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

Login Component

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

Form Handling and Validation

Handle forms with validation on both client and server sides.

Laravel Form Request

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

Vue Form Component

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

Real-time Features with Laravel Echo

Add real-time functionality using Laravel Echo and Pusher.

Install Dependencies

# Backend
composer require pusher/pusher-php-server

# Frontend
npm install --save-dev laravel-echo pusher-js

Configure Broadcasting

// 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,
    ],
],

Create Event

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

Configure Echo

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

Listen for Events

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

Building a Complete CRUD Application

Let's build a complete task management application.

Laravel Backend

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

Vue Frontend

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

TypeScript Integration

Add TypeScript for type safety.

Install TypeScript

npm install -D typescript @types/node
npm install -D @vue/tsconfig

Configure TypeScript

// tsconfig.json
{
  "extends": "@vue/tsconfig/tsconfig.dom.json",
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./resources/js/*"]
    },
    "types": ["vite/client"]
  },
  "include": ["resources/js/**/*"],
  "exclude": ["node_modules"]
}

Type-Safe Component

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

Testing Your Application

Write tests for both Laravel and Vue.

Laravel Tests

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

Vue Tests

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

Deployment Best Practices

Prepare your application for production.

Environment Configuration

# 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

Build for Production

# 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

Nginx Configuration

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

Conclusion

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.

Key Takeaways

  • Seamless Integration: Vite makes Laravel and Vue 3 work together effortlessly
  • Modern Development: Composition API and TypeScript provide excellent developer experience
  • Full-Stack Power: Handle everything from database to UI in one cohesive application
  • Production Ready: Both frameworks are battle-tested and ready for enterprise applications
  • Active Communities: Extensive documentation and community support for both ecosystems

Next Steps

  1. Explore Inertia.js: Server-side routing with SPA-like experience
  2. Add Real-time Features: Implement WebSockets and broadcasting
  3. Optimize Performance: Lazy loading, code splitting, caching strategies
  4. Enhance Security: CSRF protection, XSS prevention, input validation
  5. Scale Your Application: Load balancing, database optimization, CDN integration

Additional Resources

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!

Back to All Posts