DEV Community

Nolan Nordlund
Nolan Nordlund

Posted on

Creating a project template using Laravel, Vue 3, and Tailwind - Part 3

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
Enter fullscreen mode Exit fullscreen mode

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,
],
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
...
Enter fullscreen mode Exit fullscreen mode
// 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");
Enter fullscreen mode Exit fullscreen mode

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.
dashboard component

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>
Enter fullscreen mode Exit fullscreen mode
// 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\.\-\ \&\=]*');
...
Enter fullscreen mode Exit fullscreen mode
// 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()
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

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';
...
Enter fullscreen mode Exit fullscreen mode
// 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
...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
// 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");
Enter fullscreen mode Exit fullscreen mode
// 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'
            },
        },
    ],
};
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
  }
});
...
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
jctamayo profile image
Juan Tamayo

I started using this jetstream.laravel.com/2.x/introduc... Inertia + Vue does this easier

Collapse
 
heynolnor profile image
Nolan Nordlund

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

Collapse
 
kaii profile image
Leo • Edited

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?

Collapse
 
heynolnor profile image
Nolan Nordlund

I'd recommend posting on Stack Overflow