Courses

Vue.js 3 + Laravel 11 + Vite: SPA CRUD

Install Laravel + Vue: First Vue Component

In the first lesson of this course, we will set up the Laravel application. We will install the Vue.js JavaScript framework and make it work with Vite. Then we will load our first Vue component.

For now, that component will just show a static text "Table coming soon", but our first goal is to load something that comes from Vue and not from Laravel.

vue component loaded


Laravel Breeze: Design with Cleanup

After Laravel installation, let's start by installing Laravel Breeze.

Notice: you DON'T need Breeze to use Vue.js. It's just my personal preference: we won't use it for authentication scaffold and won't use its Vue version, it's just for some quick and simple visual design, so we will remove a lot of its parts.

composer require laravel/breeze --dev
php artisan breeze:install blade

Next, we will create just one public page for Dashboard, so we need to remove all the links from the navigation where auth is called. Both in the desktop and mobile menus.

resources/views/layouts/navigation.blade.php:

<nav x-data="{ open: false }" class="bg-white border-b border-gray-100">
<!-- Primary Navigation Menu -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<!-- Logo -->
<div class="shrink-0 flex items-center">
<a href="{{ route('dashboard') }}">
<x-application-logo class="block h-9 w-auto fill-current text-gray-800" />
</a>
</div>
 
<!-- Navigation Links -->
<div class="hidden space-x-8 sm:-my-px sm:ml-10 sm:flex">
<x-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
{{ __('Dashboard') }}
</x-nav-link>
</div>
</div>
 
<!--
<div class="hidden sm:flex sm:items-center sm:ml-6">
<x-dropdown align="right" width="48">
<x-slot name="trigger">
<button class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-none transition ease-in-out duration-150">
<div>{{ Auth::user()->name }}</div>
 
<div class="ml-1">
<svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</div>
</button>
</x-slot>
 
<x-slot name="content">
<x-dropdown-link :href="route('profile.edit')">
{{ __('Profile') }}
</x-dropdown-link>
 
<!-- Authentication -->
<form method="POST" action="{{ route('logout') }}">
@csrf
 
<x-dropdown-link :href="route('logout')"
onclick="event.preventDefault();
this.closest('form').submit();">
{{ __('Log Out') }}
</x-dropdown-link>
</form>
</x-slot>
</x-dropdown>
</div>
 
<!-- Hamburger -->
<div class="-mr-2 flex items-center sm:hidden">
<button @click="open = ! open" class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:bg-gray-100 focus:text-gray-500 transition duration-150 ease-in-out">
<svg class="h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24">
<path :class="{'hidden': open, 'inline-flex': ! open }" class="inline-flex" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
<path :class="{'hidden': ! open, 'inline-flex': open }" class="hidden" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
 
<!-- Responsive Navigation Menu -->
<div :class="{'block': open, 'hidden': ! open}" class="hidden sm:hidden">
<div class="pt-2 pb-3 space-y-1">
<x-responsive-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
{{ __('Dashboard') }}
</x-responsive-nav-link>
</div>
 
<!--
<div class="pt-4 pb-1 border-t border-gray-200">
<div class="px-4">
<div class="font-medium text-base text-gray-800">{{ Auth::user()->name }}</div>
<div class="font-medium text-sm text-gray-500">{{ Auth::user()->email }}</div>
</div>
 
<div class="mt-3 space-y-1">
<x-responsive-nav-link :href="route('profile.edit')">
{{ __('Profile') }}
</x-responsive-nav-link>
 
<!-- Authentication -->
<form method="POST" action="{{ route('logout') }}">
@csrf
 
<x-responsive-nav-link :href="route('logout')"
onclick="event.preventDefault();
this.closest('form').submit();">
{{ __('Log Out') }}
</x-responsive-nav-link>
</form>
</div>
</div>
</div>
</nav>

In the routes file, we need only one route to the index page and it can just be a view. Later in the course for routes we will use vue-router.

routes/web.php:

Route::view('/', 'dashboard')->name('dashboard');
Route::get('/', function () {
return view('welcome');
});
 
Route::get('/dashboard', function () {
return view('dashboard');
})->middleware(['auth', 'verified'])->name('dashboard');
 
Route::middleware('auth')->group(function () {
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
});
 
require __DIR__.'/auth.php';

Next, we need to tell Vue where it will be "mounted". It means the main element of the HTML structure, where Vue would live "inside of it".

Usually, it is one of the top div elements in the main layout, and to identify it, we assign id="app" to it.

resources/views/layouts/app.blade.php:

// ...
<body class="font-sans antialiased">
<div class="min-h-screen bg-gray-100">
<div class="min-h-screen bg-gray-100" id="app">
@include('layouts.navigation')
// ...

Ok, now our Blade layout is ready. Let's start working on Vue.


Installing and Configuring Vue.js

Another cleanup from Breeze: by default, Breeze adds Alpine.js but we won't be using it so we can remove it from the package.json.

package.json:

{
// ...
"devDependencies": {
"@tailwindcss/forms": "^0.5.2",
"alpinejs": "^3.10.5",
"autoprefixer": "^10.4.2",
"axios": "^1.1.2",
"laravel-vite-plugin": "^1.0",
"postcss": "^8.4.6",
"tailwindcss": "^3.1.0",
"vite": "^5.0"
},
}

Now we can install Vue and Vue loader and set up Vue.

npm install vue vue-loader

Next, we need to install the Vue Vite plugin.

npm install --save-dev @vitejs/plugin-vue

And add config to the vite.config.js.

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: {
vue: 'vue/dist/vue.esm-bundler.js',
},
},
});

Creating Vue Component

Let's add a Vue component to our Dashbord page. In the future it will show the list of Posts, so we will call its HTML tag <posts-index>.

resources/views/dashboard.blade.php:

<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ __('Dashboard') }}
</h2>
</x-slot>
 
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
<div class="p-6 text-gray-900">
{{ __("You're logged in!") }}
<posts-index></posts-index>
</div>
</div>
</div>
</div>
</x-app-layout>

Now, create a Vue component resources/js/components/Posts/Index.vue. For now, inside it, we will only put dummy static text.

resources/js/components/Posts/Index.vue:

<template>
Table coming soon.
</template>

Every Vue component consists of two parts:

  • <script>
  • <template>

In this case, we are not doing any JS operations yet, so we won't have the script part, only the template.

Now, remember that id="app" element? We need to initialize a Vue app, add the component from above and mount it to that app element.

All this needs to be done in the main resources/js/app.js file. By default with Breeze, it contains code using Alpine.js but we will remove it.

resources/js/app.js:

import './bootstrap';
 
import Alpine from 'alpinejs';
 
window.Alpine = Alpine;
 
Alpine.start();
 
import { createApp } from 'vue'
import PostsIndex from './components/Posts/Index.vue'
 
createApp({})
.component('PostsIndex', PostsIndex)
.mount('#app')

What are we doing here? Let's translate to human language:

  • We create the Vue application with createApp(), importing it beforehand
  • We attach a component to the Vue application, importing it beforehand and giving it a name of PostsIndex
  • We mount the Vue application to the #app element from the main layout

A few words about the naming of things. We are naming component PostsIndex so that we would know that it's a component for the Posts and it's a Index.vue file. But for calling components in the Vue files, there are two ways:

  1. The one we used kebab-used.
  2. And second, using PascalCase.

Vue.js supports both cases.

For the components naming we rely on the style guide and are using the PascalCase.

And now we're ready to launch our page!

Compile everything using npm run dev or npm run build and after visiting the page we should see the text Table coming soon..

vue component loaded


Webpack instead of Vite?

In Laravel 9.19 version, they switched the default from Webpack to Vite.

If, for some reason, you still want to use Webpack, or you come from older Laravel version, you can check the migration guide here.

After migrating to the Webpack, in the webpack.mix.js you will need to add vue().

webpack.mix.js:

mix.js('resources/js/app.js', 'public/js')
.vue()
.postCss('resources/css/app.css', 'public/css', [
require('postcss-import'),
require('tailwindcss'),
require('autoprefixer'),
]);
avatar

It's odd, I'm getting RollupError: Could not resolve "./components/Posts/Index.vue" from "resources/js/app.js" if I run "npm run build".

EDIT: My bad, I have managed to name the component Index.js instead of Index.vue

avatar

Work like a charm now

avatar

NOTE: If you are running sail (docker) vite wont be updating properly and appear visually broken.

Add this to your vite.config.js

server: { host: "0.0.0.0", hmr: { host: "localhost", }, },

👍 3
avatar

I got error notification on browser inspect element :

[Vue warn]: Failed to resolve component: post-index If this is a native custom element, make sure to exclude it from component resolution via compilerOptions.isCustomElement. at <App>

[Vue warn]: Property "open" was accessed during render but is not defined on instance.

and the Text "Table Coming Soon" was not showing.

EDIT : Sorry, I got typo "posts-index" with "post-index"

avatar

I got error on browser and I could not solve it please help

[vite] Internal server error: Failed to resolve import "./components/Posts/Index.vue" from "resources/js/app.js". Does the file exist? Plugin: vite:import-analysis ......

Sorry it was problem from directory view/components/Posts/Index.vue instead of resources/js/components/Posts/Index.vue