Lessons Learned Upgrading a Laravel App to Vue 3

Over the past month or so, I've been upgrading one of our applications at work to Vue 3. The app is an ecommerce storefront built on Laravel. The codebase is around 6 years old and over that time, things in the Laravel and Vue ecosystems have changed a lot. This post is my shot at describing some of the largest pain points we encountered and tips for updating your project.

Dependencies - You should own your business logic

This is one area where we could have been royally screwed and why a lot of Vue 2 apps will never be updated. When working on any project, it's almost inevitable that your project will use some external dependencies. This project is no exception! Lucky for us, the project has been around too long to have been reliant on a smorgasbord of frontend libraries.

Every open source maintainer publishes code with good intentions, but we all know that people change and nothing lasts forever. When projects get abandoned, often the changes needed to upgrade are not actually that demanding. For us, many of the dependencies only needed to rename a few lifecycle hooks to be compatible with Vue 3. But not all packages are that simple.

If your project has core business logic that is built on top of a "popular" open source library, I would highly highly highly recommend that you evaluate removing that dependency from your stack. While you save a lot of time in the beginning, this can be an extremely painful process to work your way out of if a library decides not update. Some popular libraries took years to release Vue 3 compatible versions. If your app revolves around any of these ideas, you should seriously consider creating your own components.

  • Forms

    • Both components and state management.

  • Tables

    • This includes sorting/filtering functionality.

  • Modals

  • Navigation

    • If you need any sort of "Mega Menu", you should write your own.

The key thing to ask yourself is "If this becomes unsupported, will I be screwed?" If the answer is yes, you should probably write your own. While your homemade version might not be as feature rich as the off-the-shelf solution, it also won't include any features you do not need. Luckily, the beauty of OSS, is that you can read the source and extract the ideas from those libraries that you might want to use in your version. When it comes time to perform the upgrade to Vue 4, you'll be happy for the reduced feature set. In my experience, many of our in-house components didn't need any changes to be compatible.

For some of our dependencies, it made sense for us to "fork" the package and integrate it into our repo. I put fork in air-quotes because what this actually means is I copied the source .vue file from Github, made a few syntax changes to support Vue 3 and then removed the dependency from our package.json.

Another good option to trim fat is removing your reliance on wrappers around other JS libraries. For us, one of these was a thin Vue wrapper on a JS slider plugin. Managing touch interactions, performance, and cross browser behavior is not fun, but we didn't need a component with 100 different props that were essentially passed down to the underlying vanilla JS library. The benefit of wrapping vanilla JS libs ourselves is twofold. One: we control the public API now for other devs on the team. This also means if we need to swap out to a new library in the future, hopefully it will be possible to translate the same ideas over. Two: a vanilla JS solution is less likely to break unexpectedly since browsers dedicate so much effort to backwards compatibility.

XHR/AJAX

One package we implicitly relied on heavily was Vue-Resource. This provided a convenient API for ajax requests via this.$http.post()

I don't know if this is a good thing, but a previous developer had created their own wrapper around Vue-Resource and exposed it as a global function. This made it convenient to add request and error logging in a central location, but the return value from the homegrown version was not consistent. In addition, the call signature often looked something like this:

# file.js
const params = {productId: 123, quantity: 1}
const action = customFuncToGetRouteInfo('CartController@store')

this.Ajax[action.method](action.url, {...params}, options = {})

I'm sure this was convenient at the time, but in contrast, it made it difficult to refactor. It added abstraction on top of something that wasn't really required. How often will your store method on a controller use a GET request? Probably never, and if it did change, hopefully you are testing that functionality before YOLO deploying to production.

In the end, I migrated us over to axios under the hood and kept the call signature mostly the same. In follow up tickets, the plan is to convert all uses of this custom function to axios. The nice thing about axios is the unified return format, regardless of how the server responds. I'm still contemplating if wrapping axios with a thin wrapper is worth it, but for our app, our ajax requests are very simple and don't involve huge complex payloads. If something fails, we can usually show the user an error and ask them to reload the page.

Inline-Template

The absolute worst part of this upgrade was converting our entire codebase to no longer use the inline-template attribute for a given Vue component. This feature was removed in Vue 3 and while the compatibility build supports the syntax, I don't trust it in production. Maybe that is irrational.

Back in the early days of Vue and Laravel, inline-template was a really neat way to get data from PHP into your Vue components. For things like translation strings, it was very convenient to not need a JS solution to manage output.

<my-component inline-template>
  <h1>{{ __("Hello there") }}</h1>
  <div v-html="someComputedVueState"></div>
</my-component>

Ideally, we would move all of these to dedicated .vue files, with props for all the required data and everything would be great! But in reality, we had components that essentially encompassed the entire page, and a lot of inline PHP logic was being done in the .blade.php file at runtime. Refactoring all of these to use <slot />, and scopedSlots was not an option.

For the smaller components, like a <collapsible> toggle component, I did migrate that to a .vue SFC and updated the half dozen or so PHP files to use slots; one for the toggle title text and the default slot for the content exposed when the dropdown was open.

For larger files, the best option was to use a combination of the @push and @once blade directives.

First, I created a new stack to house our Vue component templates. This stack was rendered at the bottom of our global layout.blade.php file. Importantly, this stack is rendered outside of the element that Vue is mounted on (usually #app or #root). Importantly, these stacks are only pushed to when the included blade.php file is requested. We try to only ship templates for components that could be used on the current page. For components that may be used multiple times, in an @include inside of a @foreach loop, we can take advantage of the @once directive.

# some-include.blade.php
@push('vue-templates')
  <template type="text/template" id="my-component-template">
    <p>Now we can use {{ $phpVariables }} of {{ strtolower('functions') }}</p>
  </template>
@endpush

# Vue will still mount the component here in the DOM.
<my-component></my-component>

Then, inside MyComponent.js, we can reference #my-component-template as the source.

In our project, 87 of the 200~ changed files were Blade templates, largely focused around this refactor. Over the course of the rest of the year, the goal is to migrate all of these hacky templates to use proper .vue files and slots where necessary.

Mixin Hell

The other major hurdle for our project, was untangling a mess of mixins and shared behavior. I can't begin to explain how frustrating it is to see a call to a function this.doSomething() and not being able to click through to that function definition in a file. First I need to global search, hope there aren't two mixins that share duplicate function names, and then figure out which one is actually being used in the context I'm testing.

To compound this, many mixins were instantiated on the root Vue instance, essentially making them available to every child component in the tree with Vue.prototype. Then, at the individual component level, another mixin might be included deeper in the tree that overwrites a noop method.

Another major pain point is sharing data in mixins. The behavior in Vue 2 was to deeply merge all keys in data when a new mixin is included. In Vue 3, this is no longer the case, and there is no way to opt in to the old behavior. For us, we had a few global level data objects and many mixins would extend that object to add their own configuration. After upgrading to Vue 3, this means that each mixin we include would wipe out the configuration of the one that came before it because they shared a top level data key.

My solution to this was essentially to give each mixin it's own unique object to modify in data (if necessary).

# mixin.js

# old
export default {
  data() {
    config: {
      key1: '',
      key2: null,
    }
  },
  methods: {
    // functions here.
  }
}

# new
export default {
  data() {
    uniqueConfigName: {
      key1: '',
      key2: null,
    }
  },
  methods: {
    // functions here.
  }
}

On the surface, this doesn't seem too bad, but you have to remember that these values could be used almost anywhere; in other Vue components and inline-template in Blade files. I needed to comb through every single template file for matching keys and update the usage to point to the new unique key. After updating the code, I needed to test it within the app, which was not always straightforward to test since many of these mixins interact with third party services.

Moving forward, the goal is to convert these to composables. This provides a standardized way for multiple components to access and modify the same data. You also have the benefit of click-through for functions exported from the composable. When paired with co-located SFC templates, it will be trivial to trace down the definition of a function call or variable definition.

Filters

For our app, we only had a couple filters defined. Luckily, replacing them is straightforward. My recommendation is to create a utils.js file and export functions that do the same thing as your filter. Most filters can be copied and pasted to your utils file with minimal modification. Then in your component, import the utility function and modify the template.

# old-filter.vue
<template>
  {{ props.title | spongebobCase }}
</template>

# new-utility.vue
<script>
import { spongebobCase } from '@/utils.js'
</script>
<template>
  {{ spongebobCase(props.title) }}
</template>

For apps with a lot of filters across potentially dozens/hundreds of files, this could be very slow and annoying process, especially when filters can be registered globally and used anywhere in the app tree. Thankfully, it's not a difficult refactor and mostly involves grunt work.

Conclusion

Overall, this was a ton of work. Between my effort updating the codebase and other devs on the team doing code review and testing each frontend feature, we probably spent 150+ hours on the upgrade over 2 weeks. As you also can see, there is still a lot of work left to do. This was the bare minimum needed to upgrade to Vue 3 with minimal reliance on the backcompat package. Hopefully we can fully upgrade everything to a more idiomatic approach before the end of the year when support for Vue 2 officially ends.

The PR changes are about equal for code added and removed (6k lines). Our final file change count was 209, including config files like package.json, webpack.config.js and yarn.lock and a few CSS files.

If you have questions, feel free to reach out on social media or via email. Information is elsewhere on this site. I'll probably also post this on Reddit, so feel free to search for the URL in /r/vuejs and /r/laravel.

Posted in:

Laravel VueJS