Integrate Laravel with a Vue CLI app with Hot Module Replacement and no backend API

mtdalpizzol

Matheus Dal'Pizzol

Posted on May 26, 2021

Integrate Laravel with a Vue CLI app with Hot Module Replacement and no backend API

Every time someone wants to use a Vue.js app generated by the Vue CLI with Laravel, there's always that "YOU'RE GONNA NEED A BACKEND API" guy to crush your hopes and make you feel bad/sad. Well, there's nothing wrong with that approach. But every time I hear that, I always ask myself: WHY? Why do I need to use Laravel only as a backend API just because I'm going to use Vue CLI to serve my assets?

Sometimes it seems like people get stuck in a box of how things MUST be done and simply forget how the web works.

Let's stop for a second...

Think about what the npm run serve from Vue CLI does.

It grabs some code, compiles it, lifts up a simple web server and delivers the compiled assets.

Did you notice something? it LIFTS UP a server and it DELIVERS assets. Isn't that what we do when we serve static assets from a CDN for our app? We have a server up, somewhere, that delivers static assets. That's all.

And NO ONE, EVER came down to you saying: "Oh, your serving static assets, now you need a backend API." I think you'll agree with me that this would be nonsense.

So, at the end, it seems that people forgot that when you do npm run serve and get Webpack DevServer running at http://localhost:8080, it's just like serving your assets from a CDN.

Ok! I Get it, but...

Now I see, but there's this: Vue CLI is the one responsible for generating and serving MY HTML PAGE with all the hashed .js and .css file references. THAT'S why we serve the Vue CLI result upfront and use the Laravel app as a backend API, right?

Right, but what exactly is stopping us from using the HTML page generated by Vue CLI as a view for Laravel to render instead?

Let's start from there...

Where should I create the Vue CLI app?

That's your choice, but the way I like to go about this is to make my resources folder the Vue app.

  1. Backup your resources folder to resources-bkp.
  2. Create the Vue app as a new resources folder
  3. Place your resources/lang back to where it belongs
  4. Create a new empty resources/views directory
  5. Create a resources/vue.config.js file
  6. If there's nothing important at resources-bkp, delete it
mv resources resources-bkp
vue create resources
mv resources-bkp/lang resources/lang
mkdir resources/views
nano resources/vue.config.js
rm -rf resources-bkp
Enter fullscreen mode Exit fullscreen mode

NOTICE: Keep in mind that, from now on, all of your npm commands must be run at the resources folder. I usually save some time by keeping 2 terminals open on my VSCode workspace: one at the project root and another at the resources folder. This way I can run commands at the project root (php artisan, composer) without having to stop the DevServer running on a separate terminal.

Make a base Blade template

Move your resources/public/index.html inside resources/src/ and rename it to template.blade.php.

mv resources/public/index.html resources/src/template.blade.php
Enter fullscreen mode Exit fullscreen mode

Separating development and production builds

The HTML generated during development will be different from the one generated for production. For example, link and script tags will have distinct base URLs depending on the environment:

<!-- development -->
<link href="http://localhost:8080/css/0.d09619e2.css" rel="prefetch">
<script type="text/javascript" src="http://localhost:8080/js/chunk-vendors.js"></script>

<!-- production -->
<link href="https://assets.mywebapp.com/css/0.d04519e3.css" rel="prefetch">
<script type="text/javascript" src="https://assets.mywebapp.com/js/chunk-vendors.a07692ee.js"></script>
Enter fullscreen mode Exit fullscreen mode

So, we need to keep them separated. During development, we're going to put the generated views at resources/views/devserver/, while in production, at resources/views/.

We don't want to check in which environment we are every time we need to render a view. So, instead of cluttering our code with if statements, we're gonna change our view paths config at configs/view.php.

This way, during development, Laravel will first lookup for views inside resources/views/devserver/, only if it can't find it there, it will look at resources/views/.

// config/view.php

    'paths' => (env('APP_ENV', 'production') === 'local')
        ? [
            resource_path('views/devserver'),
            resource_path('views')
        ]
        : [
            resource_path('views')
        ],
Enter fullscreen mode Exit fullscreen mode

We also don't want to commit our development views to our repository, so, add the path to your .gitignore file.

# .gitignore
/resources/views/devserver
Enter fullscreen mode Exit fullscreen mode

Regarding our assets, we're gonna dump them at public/dist/. During development, however, they are going to be served directly from memory by Webpack DevServer.

Configuring Vue CLI properly

Here's the Vue CLI config we'll need. I commented it in details.

const path = require('path')

let environmentViewsDirectory = ''
let outputDir = 'dist/'

/**
 * This is a precaution
 * In case we accidentally write dev-server files to disk,
 * we're not going to mess with our production ready dist folder
 */
if (process.env.NODE_ENV === 'development') {
  environmentViewsDirectory = 'devserver/'
  outputDir = 'dist-devserver/'
}

/**
 * A helper function to create Vue CLI page entries
 * @param {string} name The name of the JavaScript entry point for the page
 * @param {string} outputPath The path to the resulting HTML Blade file without the extension
 * @param {string} template An optional template file to be used as base for the Blade view
 * @returns 
 */
const page = (name, outputPath, template) => {
  return {
    [name]: {
      entry: `src/${name}.js`,
      filename: path.resolve(__dirname, `views/${environmentViewsDirectory}${outputPath}.blade.php`),
      template: template || 'src/template.blade.php'
    }
  }
}

module.exports = {
  /**
   * If you're not aiming for a SPA
   * You can add as many pages here as you want
   * as long as you create an entry .js file at resources/src/
   * You can also use this to create a separate template for an admin endpoint or any other endpoint you need
   */
  pages: {
    ...page('main', 'app'),
    // ...page('contact', 'contact'),
    // ...page('admin', `admin`),
  },
  // Where to dump resulting files
  outputDir: `../public/${outputDir}`,
  // The URL from which assets will be served
  // AND WHICH will be used by injected assets inside the HTML
  publicPath: process.env.VUE_APP_ASSET_URL,
  css: {
    extract: (process.env.NODE_ENV === 'development')
      ? {
        filename: 'css/[name].css'
      }
      : true
  },
  devServer: {
    // Public needs to be explicitly set since we're not using the defaults
    public: process.env.VUE_APP_ASSET_URL,
    // During development, we want to write ONLY the HTML files to the disk
    // This is needed because Laravel won't be able to read the view directly from memory
    // But we want our .js and .css assets to still be served directly from memory
    // So, we're writing to disk ONLY if the output file is at views/devserver/
    writeToDisk: (filePath) => {
      return /views\/devserver\//.test(filePath);
    },
    // Allows you to point any host to the devserver port.
    // Eg.: http://assets.localhost:8080 will also work
    // Alternatively you could use the "allowedHosts" option
    disableHostCheck: true,
    // Avoid CORS issues
    headers: {
      "Access-Control-Allow-Origin": "*",
      "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
      "Access-Control-Allow-Headers": "X-Requested-With, content-type, Authorization"
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

IMPORTANT: process.env.VUE_APP_ASSET_URL

The process.env.VUE_APP_ASSET_URL will be set at resources/.env.local and resources/.env.production.

At resources/.env.local, VUE_APP_ASSET_URL must be your DevServer host (don't forget the trailing slash):

Create the files.

nano resources/.env.local
nano resources/.env.production
Enter fullscreen mode Exit fullscreen mode
# resources/.env.local
VUE_APP_ASSET_URL=http://localhost:8080/
Enter fullscreen mode Exit fullscreen mode

At resources/.env.production it depends. Usually, in production, I setup a subdomain with its root pointing to public/dist/. This way I can easily cache all of its contents.

In this case, VUE_APP_ASSET_URL will simply be the subdomain. Otherwise, we'll have to add dist/ at the end of the address.

# resources/.env.production

# subdomain pointing to public/dist/
VUE_APP_ASSET_URL=https://assets.mywebapp.com/

# same domain
VUE_APP_ASSET_URL=http://www.mywebapp.com/dist/
Enter fullscreen mode Exit fullscreen mode

Now you can run npm run serve from the resources folder.

cd resources
npm run serve
Enter fullscreen mode Exit fullscreen mode

Routing

Now, there's two ways you can go about routing:

Method 1

Generate a separate view file for each page (see the comment on the resources/vue.config.js pages property). This means that every time you create a new page, you'll need to add a new entry .js file at resources/src/ (Eg.: resources/src/mypageentry.js) and add it to that object, like mentioned in that comment (...page('mypageentry', 'mybladefile')). In this case, you simply return views like you always did with Laravel.

// routes/web.php

Route::get('/', function () {
    return view('app');
});

// Route::get('/contact', function () {
//    return view('mybladefile');
// });
Enter fullscreen mode Exit fullscreen mode

Method 2 (recommended)

Use the same view for every request and decide which component to load on the client side based on a page key. I prefer this approach, but it'll require some work to get Code Splitting working. This is to avoid loading components that are not required on the current page.

Create a dedicated folder for your page components.

mkdir resources/src/pages
Enter fullscreen mode Exit fullscreen mode

Create a Home.vue page.

// resources/src/pages/Home.vue

<template>
  <div class="home">
    <h1>Hello from Home component</h1>
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

Change your App.vue.

// resources/src/App.vue

<template>
  <div id="app">
    <Page />
  </div>
</template>

<script>
export default {
  name: 'App',
  components: {
    Page: () => import('@/pages/' + window.app.page.component + '.vue')
  }
}
</script>
Enter fullscreen mode Exit fullscreen mode

As you can see, we're now getting which page should be rendered through a global object attached to the window object. To get this in place, add the following to your resources/src/template.blade.php:

<head>
  <!-- ... -->
  <script>
    window.app = @json($jsapp)
  </script>  
</head>
Enter fullscreen mode Exit fullscreen mode

Then, pass the data through your route:

// routes/web.php

Route::get('/', function () {
    return view('app', [
        'jsapp' => [
            'page' => [
                'component' => 'Home'
            ]
        ]
    ]);
});
Enter fullscreen mode Exit fullscreen mode

This is a bit bitter... A little sugar, please?

Off course we're not gonna be passing all this array every time we render a view. We're not troglodytes!

Let's create a View Macro to simplify this task.

// app/Providers/AppServiceProvider.php

public function boot()
{
    View::macro('vue', function ($component, $data = []) {
        return view('app', [
            'jsapp' => [
                'page' => [
                    'component' => $component,
                    'data' => $data
                ]
            ]
        ]);
    });
}
Enter fullscreen mode Exit fullscreen mode

Then we can just do this:

// routes/web.php

Route::get('/', function () {
    return View::vue('Home'); // Cool, huh?
});
Enter fullscreen mode Exit fullscreen mode

But... how do I pass server data to Vue components?

The server side part is done. Our View::vue macro accepts an array of data as a second parameter.

Let's make that data easily accessible to all of our components using a global mixin (if you have a better way of doing this, go ahead).

// resources/src/main.js

Vue.mixin({
    data () {
      return {
        page: window.app.page.data
      }
    }
})

new Vue({
  render: h => h(App)
}).$mount('#app')
Enter fullscreen mode Exit fullscreen mode

Now, pass some data from your route:

// routes/web.php

Route::get('/', function () {
    return View::vue('Home', [
        'title' => 'Imaginations from the server side!!'
    ]);
});
Enter fullscreen mode Exit fullscreen mode

Now we can simply use that title in our components:

// resources/scr/components/Home.vue

<template>
  <div class="home">
    <h1>{{ page.title }}</h1>
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

Isn't that awesome? We have transcended the limits between the PHP server and the Vue.js components world!

What about SPAs?

Well, the second routing method we saw is the mechanism used by Inertia.js, an awesome way of creating SPAs with Laravel and Vue.js. You can learn how to integrate Inertia.js with Vue CLI here. Spoiler alert: if you followed this article, you already did 90% of the work and all you did here will be used there. In fact, everything from the Routing section of this article won't be needed. I HIGHLY recommend you to take that step further. Inertia.js is awesome.

Article repo

I've made a GitHub repo with all of this work done for you to clone and see things working for yourself.

DON'T FORGET TO CREATE YOUR resources/.env.local FILE AND SET THE VUE_APP_ASSET_URL ENVIRONMENT VARIABLE

Conclusion

As we saw, using Laravel as a simple API backend for your Vue CLI app isn't the only way to get an app with a Vue.js based frontend. You can use the full routing power and other awesome features of Laravel like you always did and just use Vue.js for what it was initially intended: working with the view layer of your app. And, if you're all about SPAs, you can take one more step and integrate Inertia.js with a Vue CLI app.

Hope this helps anyone out there. Cheers!

💖 💪 🙅 🚩
mtdalpizzol
Matheus Dal'Pizzol

Posted on May 26, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related