Integrate Laravel with a Vue CLI app with Hot Module Replacement and no backend API
Matheus Dal'Pizzol
Posted on May 26, 2021
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.
- Backup your
resources
folder toresources-bkp
. - Create the Vue app as a new
resources
folder - Place your
resources/lang
back to where it belongs - Create a new empty
resources/views
directory - Create a
resources/vue.config.js
file - 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
NOTICE: Keep in mind that, from now on, all of your
npm
commands must be run at theresources
folder. I usually save some time by keeping 2 terminals open on my VSCode workspace: one at the project root and another at theresources
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
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>
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')
],
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
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"
}
}
}
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
# resources/.env.local
VUE_APP_ASSET_URL=http://localhost:8080/
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/
Now you can run npm run serve
from the resources
folder.
cd resources
npm run serve
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');
// });
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
Create a Home.vue
page.
// resources/src/pages/Home.vue
<template>
<div class="home">
<h1>Hello from Home component</h1>
</div>
</template>
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>
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>
Then, pass the data through your route:
// routes/web.php
Route::get('/', function () {
return view('app', [
'jsapp' => [
'page' => [
'component' => 'Home'
]
]
]);
});
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
]
]
]);
});
}
Then we can just do this:
// routes/web.php
Route::get('/', function () {
return View::vue('Home'); // Cool, huh?
});
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')
Now, pass some data from your route:
// routes/web.php
Route::get('/', function () {
return View::vue('Home', [
'title' => 'Imaginations from the server side!!'
]);
});
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>
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!
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
May 26, 2021