This article was originally published at projectrebel.io.
Creating a project template using Laravel, Vue 3, and Tailwind
In this installment of the project template series, we're going to add a single page application to our Laravue3 Breeze template that we created last time. We'll be using Laravel's Sanctum package. Sanctum provides an extremely easy way for a hosted SPA to make API requests for data from our Laravel backend. If you want to jump right into a project now, you can download it from Github.
Instead of authenticating directly through the single page application, I prefer to use the basic authentication provided by Laravel to gain access to a view that serves your SPA. In my opinion, this is a simpler way to get started. Once the user is authenticated, Sanctum will provide the authentication for individual API requests.
Installing Sanctum
Like most of Laravel's first-party packages, installation is quite simple. We'll install via composer
, publish the assets, and run the necessary migrations.
composer require laravel/sanctum
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
php artisan migrate
Next, we'll add Sanctum's middleware to our api
middleware group.
// app/Http/Kernel.php
'api' => [
\Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
'throttle:api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
By default, Laravel includes a simple route in routes/api.php
which simply returns the User
model of the authenticated user. All we need to do to allow our SPA to access this route is to update the middleware it specifies.
// routes/api.php
...
Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
return $request->user();
});
Consuming the API
Now that we have a functioning endpoint, let's make a call from a new component and display the output. We'll use the composition API for this component.
// resources/js/components/Dashboard.vue
<template>
<h1 class="text-2xl font-bold mb-2">Dashboard</h1>
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
<div class="p-6 bg-white border-b border-gray-200">
{{ user }}
</div>
</div>
</template>
<script>
import { ref, onMounted } from 'vue';
export default {
setup(props, {}) {
const user = ref({});
function getUser() {
axios.get('/api/user/')
.then(response => user.value = response.data)
.catch(error => user.value = error);
}
onMounted(() => {
getUser();
});
return {
user
}
},
};
</script>
Before we register and mount the component, let's review it quickly. We are making use of Vue's mounted
lifecycle hook to run a function that uses axios
(which is bound to the window within resources/js/bootstrap.js
) to fetch the user data via a get request. The result will be displayed whether the call is successful or not. Let's get it hooked up to a Laravel view.
// resources/views/dashboard.blade.php
...
<div id="app" class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<dashboard />
</div>
</div>
...
// resources/js/app.js
require("./bootstrap");
require('alpinejs');
import { createApp } from "vue";
import Home from "./components/Home.vue";
import Dashboard from "./components/Dashboard.vue";
const app = createApp({
components: {
Home,
Dashboard
}
});
app.mount("#app");
Register a new user at /register
and then you will be redirected to the dashboard
view where our new component will spit out your user data.
That's it! We've got a consumable API and a workflow to build new features. At this point, you are ready to run wild but it wouldn't be quite right to leave with a view that is a mix of Blade templates and Vue components. Let's create a nice starting point for SaaS friendly frontend.
If you are running into issues with 401 errors, you might be making the request from a domain that isn't included in Sanctum's `stateful` configuration. Since I like to use BrowserSync and tend to jump between different projects, sometimes I end up running my frontend from `localhost:3002` which is not included by default.
Building the app
Let's move to the frontend and put together a simple framework so we can really say that we have a SPA. The basic plan will be to create the shell of a typical admin dashboard and install Vue Router so we are able to navigate between newly added components.
Let's start by adding a new blade file that will display our app shell and nothing more. Then we'll update our routes to serve the view from a dedicated controller.
// 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">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ config('app.name', 'Laravel') }}</title>
<!-- Styles -->
<link rel="stylesheet" href="{{ asset('css/app.css') }}">
<!-- Scripts -->
<script src="{{ asset('js/app.js') }}" defer></script>
</head>
<body>
<div id="app">
<app :user="{{ $user }}" />
</div>
</body>
</html>
// routes/web.php
...
use App\Http\Controllers\AppController;
...
Route::get('/app', [AppController::class, 'index'])->name('app');
Route::get('/app/{vue_capture?}', [AppController::class, 'app'])->where('vue_capture', '[\/\w\.\-\ \&\=]*');
...
// app/Http/Controllers/AppController.php
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class AppController extends Controller
{
public function index()
{
return redirect('app/dashboard');
}
public function app()
{
return view('app', [
'user' => auth()->user()
]);
}
}
The second of those 2 routes is a little different than what you would typically see. We are simply using regex to find any route that should be handled by Vue and then routing it to the blade view that serves our app.
Since we are going to use /app
to serve our SPA, we'll want to update the route we are redirected to upon logging in. Let's also update the route used in welcome.blade.php
when users are logged in.
// app/Providers/RouteServiceProvider.php
...
public const HOME = '/app';
...
// resources/views/welcome.blade.php
...
@auth
<a href="{{ url('/app') }}" class="text-sm text-gray-700 underline">Dashboard</a>
@else
<a href="{{ route('login') }}" class="text-sm text-gray-700 underline">Login</a>
@if (Route::has('register'))
<a href="{{ route('register') }}" class="ml-4 text-sm text-gray-700 underline">Register</a>
@endif
@endauth
...
Next, we'll install Vue Router 4 which supports Vue 3. Not the best versioning but hey, what can you do? We'll add a routes.js
file, create the router in app.js
and add it to our Vue instance.
npm install vue-router@4
// resources/js/app.js
...
import { createRouter, createWebHistory } from 'vue-router';
import routes from './routes.js';
const router = createRouter({
history: createWebHistory(),
routes: routes.routes,
});
...
app.use(router);
app.mount("#app");
// resources/js/routes.js
import Dashboard from './components/Dashboard.vue';
import AnotherComponent from './components/AnotherComponent.vue';
export default {
routes: [
{
path: '/app/dashboard',
component: Dashboard,
name: 'dashboard',
meta: {
title: 'Dashboard'
},
},
{
path: '/app/another-component',
component: AnotherComponent,
name: 'another-component',
meta: {
title: 'Another Component'
},
},
],
};
We are adding a placeholder component in our routes.js
file just so we can see that Vue Router is working by moving between Dashboard
and AnotherComponent
.
// resources/js/components/AnotherComponent.vue
<template>
<h1 class="text-2xl font-bold">Another Component</h1>
</template>
<script>
export default {};
</script>
Now we just need the app layout to put it all together. There is a lot happening in the layout and we'll make a lot of changes in the next post so feel free to copy it for now.
// resources/js/components/App.vue
<template>
<div class="h-screen flex overflow-hidden bg-gray-50">
<div v-show="showMenu" class="md:hidden">
<div class="fixed inset-0 flex z-40">
<div class="fixed inset-0">
<div class="absolute inset-0 bg-gray-600 opacity-75"></div>
</div>
<div v-show="showMenu" class="relative flex-1 flex flex-col max-w-xs w-full bg-white">
<div class="absolute top-0 right-0 -mr-12 pt-2">
<button @click="showMenu = false" class="ml-1 flex items-center justify-center h-10 w-10 rounded-full focus:outline-none">
<span class="sr-only">Close sidebar</span>
<svg class="h-6 w-6 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="flex-1 h-0 pt-5 pb-4 overflow-y-auto">
<div class="flex-shrink-0 flex items-center px-4">
<img class="h-8 w-auto" src="/images/laravue3-logo.png" alt="logo">
</div>
<nav class="mt-5 px-2 space-y-1">
<router-link
v-for="(route, index) in $router.options.routes"
:key="index"
:to="{ name: route.name }"
exact
@click="showMenu = false"
class="hover:bg-gray-50 text-gray-600 hover:text-gray-900 group flex items-center px-2 py-2 text-base font-medium rounded-md"
>
{{ route.meta.title }}
</router-link>
</nav>
</div>
<div class="flex-shrink-0 flex border-t border-gray-200 p-4">
<a href="#" class="flex-shrink-0 group block">
<div class="flex items-center">
<div>
<img class="inline-block h-10 w-10 rounded-full" src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixqx=DS9XwDWeLa&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80" alt="">
</div>
<div class="ml-3">
<p class="text-base font-medium text-gray-700 group-hover:text-gray-900">
{{ user.name }}
</p>
<p class="text-sm font-medium text-gray-500 group-hover:text-gray-700">
View profile
</p>
</div>
</div>
</a>
</div>
</div>
<div class="flex-shrink-0 w-14"></div>
</div>
</div>
<div class="hidden md:flex md:flex-shrink-0">
<div class="flex flex-col w-64">
<div class="flex flex-col h-0 flex-1 border-r border-gray-200 bg-white">
<div class="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
<div class="flex items-center flex-shrink-0 px-4">
<img class="h-8 w-auto" src="/images/laravue3-logo.png" alt="logo">
</div>
<nav class="mt-5 flex-1 px-2 bg-white space-y-1">
<router-link
v-for="(route, index) in $router.options.routes"
:key="index"
:to="{ name: route.name }"
exact
class="hover:bg-gray-50 text-gray-600 hover:text-gray-900 group flex items-center px-2 py-2 text-sm font-medium rounded-md"
>
{{ route.meta.title }}
</router-link>
</nav>
</div>
<div class="flex-shrink-0 flex border-t border-gray-200 p-4">
<a href="#" class="flex-shrink-0 w-full group block">
<div class="flex items-center">
<div>
<img class="inline-block h-9 w-9 rounded-full" src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixqx=DS9XwDWeLa&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80" alt="">
</div>
<div class="ml-3">
<p class="text-sm font-medium text-gray-700 group-hover:text-gray-900">
{{ user.name }}
</p>
<p class="text-xs font-medium text-gray-500 group-hover:text-gray-700">
View profile
</p>
</div>
</div>
</a>
</div>
</div>
</div>
</div>
<div class="flex flex-col w-0 flex-1 overflow-hidden">
<div class="md:hidden pl-1 pt-1 sm:pl-3 sm:pt-3">
<button @click="showMenu = true" class="-ml-0.5 -mt-0.5 h-12 w-12 inline-flex items-center justify-center rounded-md text-gray-500 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500">
<span class="sr-only">Open sidebar</span>
<svg class="h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</div>
<main class="flex-1 relative z-0 overflow-y-auto focus:outline-none" tabindex="0">
<div class="py-6">
<div class="max-w-7xl mx-auto px-4 sm:px-6 md:px-8">
<router-view></router-view>
</div>
</div>
</main>
</div>
</div>
</template>
<script>
import { ref } from 'vue';
export default {
props: {
user: {
type: Object,
required: true,
}
},
setup(props, {}) {
const showMenu = ref(false);
return {
showMenu,
user: props.user
}
}
}
</script>
Now register App.vue
with our root Vue instance and we are golden.
// resources/js/app.js
...
import App from "./components/App .vue";
...
const app = createApp({
components: {
App,
Home,
Dashboard
}
});
...
Conclusion and next steps
We've got a functional SPA! We should be in a great position to build out the backend and do some basic frontend work to show the results. I'll do one more post to flesh out the front end. We'll set up the frontend so it can be rapidly prototyped just like the backend. In the meantime, let me know how it's going! Leave a comment and tell me what you'd like to see next.
Top comments (4)
I started using this jetstream.laravel.com/2.x/introduc... Inertia + Vue does this easier
@jctamayo Jetstream is really cool for sure. I ultimately decided that I wanted to build my own because I was already deeply invested in Vue and I felt like both Inertia and Livewire made my workflow messier. Just personal preference really.
Greetings, I don't know if this is the right place to ask this question but I hope you can help me. I am trying to connect my Laravel application to CiscoWebex, currently I can perform OAuth but I have problems with SSO. Sanctum could help me in this step?
I'd recommend posting on Stack Overflow