Retrofitting Inertia.js + Vue.js + Rollup.js into Laravel 7.x

#javascript #laravel #php #rollup.js #vue.js

Laravel + Inertia.js + Vite provides a great development experience. It is ideal for any fullstack project you're working on. When I recently rebuilt my blog from scratch in Laravel 11.x, I loved working with Vite. Vite is a modern JavaScript bundler with opinionated defaults that is easy to setup. You pretty much point it at your JavaScript file and go.

Today, Vite is probably the defacto JavaScript bundler for most frontend frameworks, but that wasn't always the case. Besides, not all projects fit the opinionated way that Vite bundles your JavaScript. On a recent project I ran into some issues with Vite, so I went on a quest to find an alternative.

Under the hood, Vite is really just a plugin on top of Rollup.js with some opinionated defaults. It's great in a vacuum, for instance when starting a new project. However, it can be a pain to adopt for established and mature projects. I've found that for all its greatness, Vite lacks in its ability to support legacy projects. Rollup.js, on the other hand, is quite flexible. It can be configured for multiple entry points (which was a deal-breaker on my project), and its plugin architecture enables you to customize its behavior to fit your needs. Today I will show you how I configured Rollup.js to build both vanilla JavaScript and an Inertia.js app with a single configuration.

Starting Point

Since this is a legacy project, we have an existing configuration that is bundling a plain old Vue app. This project was previously using Laravel Mix / Webpack, but recently switched to Rollup.js. You can get the project on my GitHub:

fstrube/example-legacy-project - GitHub

Make sure you follow the instructions in the README.md of the repo.

Hint. If you're struggling with a Webpack to Rollup.js migration in the real world, take a look at the git history of the repository to see how I did it.

If all went well, we should have our built JavaScript in public/js/app.js and our CSS in public/css/app.css.

So what exactly happened? When we ran the npm run dev command, Rollup.js took our JavaScript source code and passed it through a number of plugins to transpile it into a single JavaScript bundle.

Let's take a look at the Rollup.js configuration file:

rollup.config.js

const { defineConfig } = require('rollup');
const alias = require('@rollup/plugin-alias');
const commonjs = require('@rollup/plugin-commonjs');
const json = require('@rollup/plugin-json');
const postcss = require('rollup-plugin-postcss');
const replace = require('@rollup/plugin-replace');
const resolve = require('@rollup/plugin-node-resolve');
const scss = require('rollup-plugin-scss');
const vue = require('rollup-plugin-vue');

module.exports = defineConfig([
    {
        input: 'resources/js/app.js',
        output: {
            dir: 'public/js',
            format: 'iife',
        },
        plugins: [
            alias({
                '@': 'resources/js',
            }),
            commonjs({
                exclude: /.*\.vue\b/,
            }),
            json(),
            postcss(),
            replace({
                'process.env.NODE_ENV': JSON.stringify('production'),
            }),
            resolve({
                browser: true,
            }),
            vue(),
        ],
    },
    {
        input: 'resources/sass/app.scss',
        output: {
            file: 'public/css/app.css',
        },
        plugins: [
            scss({
                fileName: 'app.css'
            }),
        ]
    }
]);

Let's break it down. In the Rollup.js configuration we declared two inputs, one for our JavaScript and the other for our Sass styles. We'll break it down even further and go into the many plugins that we're using for each one.

For resources/js/app.js, we need the following plugins:

  • alias - Provides a handy way to reference scripts, relative to the root of our project. The way this is configured, if we import @/script.js it will translate that to resources/js/script.js. You can see how this will be useful as our project grows. Basically, we won't have to use relative paths in our scripts, avoiding headaches down the line.
  • commonjs - Since our project has a history, we haven't yet migrated everything to EcmaScript Modules (ESM) syntax. The commonjs() plugin allows us to require() as usual. We configured it to ignore *.vue files, since they will be using ESM imports.
  • json - This one is pretty straightforward. It allows importing JSON files.
  • postcss - For styles in our Vue components.
  • replace - This one gets rid any references to process.env.NODE_ENV.
  • resolve - This one is pretty essential as it allows importing node_modules without requiring an absolute path. It's important to set browser: true here, so Rollup knows we're not in a Node.js environment.
  • vue - Last but not least we need to parse our Single File Components (SFC) properly, and for that we need the Vue plugin for Rollup.

For resources/css/app.scss, we're being a little sneaky with our output. We're telling Rollup to output the JavaScript (?!) to app.css. This is okay because, as it turns out, there is no JavaScript in the Sass (duh!). So Rollup creates an empty file, but immediately after that, the scss() plugin overwrites it with our compiled CSS. What we're left with is a clean file structure, with no empty JavaScript files to clean up.

Hello, Inertia.js!

The app that we have is a simple X.com clone, called Postr. Just a text box that you can type into and create a public post. We've got all the standard login and auth structure as well.

localhost postr app.png

Now, we are going to introduce Inertia.js to the mix. Unfortunately, in this scenario our dev team is small and we don't have the capacity to rewrite our legacy code right now, so we decided to build both old and new side-by-side. We're going to use Inertia.js to add a new area to our app that displays all the posts for a specific user.

First let's get all of our dependencies.

JavaScript

We need the Inertia.js client library.

npm install --save-dev @inertiajs/vue2

PHP

We also need the Inertia.js backend so that Laravel can talk to our frontend app. Since our app is a legacy app, we're going with v0.1.0 of Inertia.js even though they are well past v1.x. If we wanted to use the latest and greatest, then we'd have to upgrade our Laravel version beyond 8.x.

composer require inertiajs/inertia-laravel:v0.1.0 tightenco/ziggy
php artisan inertia:middleware

Now that we have our dependencies, let's get into some code. If you want to skip ahead, you can checkout the inertia.js branch of the git repo.

git checkout inertia.js
composer install

Building the app

We're going to create a new JavaScript entrypoint that creates our Inertia.js app and mounts it to the page. This code is pretty much taken straight from the Inertia.js docs.

resources/js/inertia.js

import Vue from 'vue'
import { createInertiaApp } from '@inertiajs/vue2'

createInertiaApp({
    resolve: name => {
        const pages = import.meta.glob('./pages/**/*.vue', { eager: true })
        return pages[`./pages/${name}.vue`]
    },
    setup({ e1, App, props, plugin }) {
        Vue.use(plugin)

        // Make Ziggy route() function accessible from all Vue components
        Vue.mixin({ methods: { route } })

        new Vue({
            render: h => h(App, props),
        }).$mount(el)
    },
})

We'll add a new route to our existing routes/web.php file. Here's a diff of what that looks like

routes/web.php

diff --git a/routes/web.php b/routes/web.php
index c16aa89f..b80160a4 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -17,3 +17,5 @@ use Illuminate\Support\Facades\Route;
 Auth::routes();

 Route::get('/', [App\Http\Controllers\HomeController::class, 'index']);
+
+Route::get('/author/{author}', [\App\Http\Controllers\AuthorController::class, 'sh
ow']);

We're also in need of a controller and a corresponding Blade view for our page.

app/Http/Controllers/AuthorController.php

<?php

namespace App\Http\Controllers;

use App\Post;
use App\User;
use Illuminate\Http\Request;
use Inertia\Inertia;

class AuthorController extends Controller
{
    public function show(Request $request, User $author)
    {
        return inertia()->render('Author/Show', [
            'user' => auth()->user(),
            'author' => $author,
            'posts' => Post::with('author')->whereHas('author', function($query) use ($author) {
                $query->where('id', $author->id);
            })->orderBy('created_at', 'desc')->get(),
        ]);
    }
}

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">

    <!-- CSRF Token -->
    <meta name="csrf-token" content="{{ csrf_token() }}">

    <title>{{ config('app.name', 'Laravel') }}</title>

    <!-- Fonts -->
    <link rel="dns-prefetch" href="//fonts.gstatic.com">
    <link href="https://fonts.googleapis.com/css?family=Nunito" rel="stylesheet">

    <!-- Styles -->
    <link href="{{ asset('css/app.css') }}" rel="stylesheet">

    @auth
    <meta name="api-token" content="{{ auth()->user()->api_token }}">
    @endif

    @routes
</head>
<body>
    @inertia

    <script src="{{ asset('js/inertia.js') }}"></script>
</body>
</html>

And now, we need to make our page component in Vue.js. We need two files for that: an App.vue and Show.vue.

resources/js/pages/Author/Show.vue

<template>
    <AppLayout>
        <div class="container">
            <div class="row justify-content-center">
                <div class="col-md-8">
                    <p>Viewing posts by {{ author.name }}...</p>
                    <p v-if="!posts.length">
                        There are no posts by this user.
                    </p>
                    <div class="card my-2" v-for="post in posts" :key="post.id">
                        <div class="card-body">
                            <h6 class="card-subtitle mb-2 text-muted">{{ (post.author || {}).name || 'Somebody' }} said...</h6>
                            <p class="card-text">{{ post.content }}</p>
                            <a class="card-link">Repost</a>
                            <a class="card-link">Like</a>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </AppLayout>
</template>

<script>
    import { defineComponent } from 'vue'
    import AppLayout from '../../layouts/App.vue'

    export default defineComponent({
        props: {
            author: Object,
            posts: Array,
        },
        components: {
            AppLayout,
        }
    })
</script>

The App.vue file contains our header and nav.

resources/js/layouts/App.vue

<template>
    <div id="app">
        <nav class="navbar navbar-expand-md navbar-light bg-white shadow-sm">
            <div class="container">
                <a class="navbar-brand" href="/">
                    Postr
                </a>
                <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
                    <span class="navbar-toggler-icon"></span>
                </button>

                <div class="collapse navbar-collapse" id="navbarSupportedContent">
                    <!-- Left Side Of Navbar -->
                    <ul class="navbar-nav mr-auto">

                    </ul>

                    <!-- Right Side Of Navbar -->
                    <ul class="navbar-nav ml-auto">
                        <!-- Authentication Links -->
                        <template v-if="!$page.props.user">
                            <li class="nav-item">
                                <a class="nav-link" :href="route('login')">Login</a>
                            </li>
                            <li v-if="route().has('register')" class="nav-item">
                                <a class="nav-link" :href="route('register')">Register</a>
                            </li>
                        </template>
                        <template v-else>
                            <li class="nav-item dropdown">
                                <a id="navbarDropdown" class="nav-link dropdown-toggle" href="#" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" @click.prevent="showNavDropdown = !showNavDropdown">
                                    {{ $page.props.user.name }}
                                </a>

                                <div class="dropdown-menu dropdown-menu-right" aria-labelledby="navbarDropdown" :class="{show: showNavDropdown }">
                                    <a class="dropdown-item" :href="route('logout')"
                                       @click.prevent="logout()">
                                        Logout
                                    </a>
                                </div>
                            </li>
                        </template>
                    </ul>
                </div>
            </div>
        </nav>

        <main class="py-4">
            <div class="container">
                <div class="row justify-content-center">
                    <div class="col-md-8">
                        <slot />
                    </div>
                </div>
            </div>
        </main>
    </div>
</template>

<script>
    import { defineComponent } from 'vue'

    export default defineComponent({
        data() {
            return {
                showNavDropdown: false,
            }
        },
        methods: {
            logout() {
                // TODO implement logout
            }
        }
    })
</script>

Configuring our Rollup.js Build

To build our new script, we are going to add an entry to our rollup.config.js file.

rollup.config.js

const { defineConfig } = require('rollup');
const alias = require('@rollup/plugin-alias');
const commonjs = require('@rollup/plugin-commonjs');
const json = require('@rollup/plugin-json');
const postcss = require('rollup-plugin-postcss');
const replace = require('@rollup/plugin-replace');
const resolve = require('@rollup/plugin-node-resolve');
const scss = require('rollup-plugin-scss');
const vue = require('rollup-plugin-vue');

module.exports = defineConfig([
    {
        input: 'resources/js/app.js',
        output: {
            dir: 'public/js',
            format: 'iife',
        },
        plugins: [
            alias({
                '@': 'resources/js',
            }),
            commonjs({
                exclude: /.*\.vue\b/,
            }),
            json(),
            postcss(),
            replace({
                'process.env.NODE_ENV': JSON.stringify('production'),
            }),
            resolve({
                browser: true,
            }),
            vue(),
        ],
    },
    {
        input: 'resources/js/inertia.js',
        output: {
            dir: 'public/js',
            format: 'iife',
        },
        plugins: [
            alias({
                '@': 'resources/js',
            }),
            commonjs({
                exclude: /.*\.vue\b/,
            }),
            json(),
            postcss(),
            replace({
                'process.env.NODE_ENV': JSON.stringify('production'),
            }),
            resolve({
                browser: true,
            }),
            vue(),
        ],
    },
    {
        input: 'resources/sass/app.scss',
        output: {
            file: 'public/css/app.css',
        },
        plugins: [
            scss({
                fileName: 'app.css'
            }),
        ]
    }
]);

The change we made was to add a new entry point: resources/js/users.js.

After running npm run dev, everything seems fine, but when we navigate to the author page we see a white screen of death. What gives? Let's pop open the console and find out.

import.meta.glob undefined is not a function.png

Uncaught (in promise) TypeError: undefined is not a function

We immediately see that we have a JavaScript error. The error message itself is not very helpful, but if we look where it originates, it becomes obvious.

It turns out that Rollup.js needs specific instructions on how to handle some of our new code. Now is a good time to get into how Rollup.js plugins actually work.

Rollup.js Plugin Architecture

Rollup plugins allow you to transform your code as it is being built. There are a number of hooks that you can tie into in order to compile, minify, or transform your code. In this particular instance, we need to tell Rollup how to hande our import.meta.glob() call.

Fix #1 - Install rollup-plugin-import-meta-glob

Luckily, there is already a plugin for this very purpose.

npm i --save-dev rollup-plugin-import-meta-glob

Then we need to add it into our rollup.config.js. Below is a diff showing where we do so.

rollup.config.js

diff --git a/rollup.config.js b/rollup.config.js
index 911bc173..1a811db9 100644
--- a/rollup.config.js
+++ b/rollup.config.js
@@ -1,18 +1,19 @@
 const { defineConfig } = require('rollup');
 const alias = require('@rollup/plugin-alias');
 const commonjs = require('@rollup/plugin-commonjs');
+const glob = require('rollup-plugin-import-meta-glob');
 const json = require('@rollup/plugin-json');
 const postcss = require('rollup-plugin-postcss');
 const replace = require('@rollup/plugin-replace');
 const resolve = require('@rollup/plugin-node-resolve');
 const scss = require('rollup-plugin-scss');
 const vue = require('rollup-plugin-vue');

 module.exports = defineConfig([
     {
         input: 'resources/js/app.js',
         output: {
             dir: 'public/js',
             format: 'iife',
         },
         plugins: [
@@ -34,30 +35,31 @@ module.exports = defineConfig([
         ],
     },
     {
         input: 'resources/js/users.js',
         output: {
             dir: 'public/js',
             format: 'iife',
         },
         plugins: [
             alias({
                 '@': 'resources/js',
             }),
             commonjs({
                 exclude: /.*\.vue\b/,
             }),
+            glob({ include: /\.js\b/, }),
             json(),
             postcss(),
             replace({

Let's give our build another try, shall we?

npm run dev

Hmmm. We now have a new error.

[rollup] [!] RollupError: Invalid value "iife" for option "output.format" - UMD and IIFE output formats are not supported for code-splitting builds.

Fix #2 - Avoid code-splitting with inlineDynamicImports

What is code-splitting?

In Rollup, "code-splitting" refers to the ability to break down your application's code into smaller, independently loadable chunks, allowing only the necessary parts to be loaded on demand, which significantly improves the initial loading time and overall performance of your web application by reducing the initial bundle size.

Unfortunately, the output format of iife is incompatible with code-splitting, but don't worry. We can tell Rollup to avoid code-splitting by inlining our dynamic imports.

diff --git a/rollup.config.js b/rollup.config.js
index 0bcc770e..7fbc74f4 100644
--- a/rollup.config.js
+++ b/rollup.config.js
@@ -37,10 +37,11 @@ module.exports = defineConfig([
     {
         input: 'resources/js/users.js',
         output: {
             dir: 'public/js',
             format: 'iife',
+            inlineDynamicImports: true,
         },
         plugins: [
             alias({
                 '@': 'resources/js',
             }),

Fix #3 - Calling imports correctly

cant define property _Ctor object is not extensible.png

TypeError: can't define property "_Ctor": object is not extensible

The last error we have to tackle, and this one took me a while to debug, only for it to be a two-character fix. You know how we're doing import.meta.glob() to find all our Vue pages? Well, turns out we're supposed to execute the function as we're returning it, in order for our import to actually work. Here's the diff to show you how small this little detail really is.

diff --git a/resources/js/users.js b/resources/js/users.js
index ce4cd67c..3824eeb3 100644
--- a/resources/js/users.js
+++ b/resources/js/users.js
@@ -2,11 +2,11 @@ import Vue from 'vue'
 import { createInertiaApp } from '@inertiajs/vue2'

 createInertiaApp({
     resolve: name => {
         const pages = import.meta.glob('./pages/**/*.vue')
-        return pages[`./pages/${name}.vue`]
+        return pages[`./pages/${name}.vue`]()
     },
     setup({ el, App, props, plugin }) {
         Vue.use(plugin)
         Vue.mixin({ methods: { route } })

See those parenthesis? They make all the difference. Our app now loads! Huzzah!

localhost author posts.png

Conclusion

I hope this article was helpful and gives you an idea of what it is like to work with old software projects. I know I go into a lot of detail on the errors, but I feel like it's a necessity. Errors always come up, and most times they are mysterious and broad. One technique that comes in handy is just starting small (with "Hello, world!" or something similar). If you can get that working, then start adding in more layers, like building a cake.