/

Vue 2 in a Symfony app, before and after a build step

2,076 words, about 9 min read

The first Vue component I put into this app rendered nothing. Not an error in the console, not a half-drawn table: an empty div where the order lines were supposed to be, and a Twig exception in the profiler reading Variable "line" does not exist. It took me a while to understand that Twig had eaten the template before the browser ever saw it.

That was five months ago. The app is a back office: Symfony 4.2 (4.2.4 as of two weeks ago), Twig, MySQL, about forty routes, all server rendered, and three screens where server rendering was making people miserable. This is how Vue 2 got in, first with no build step at all and then with Webpack Encore, and what it cost.

Why not a single page app

The proposal on the table last year was a rewrite: keep Symfony as a JSON API, move the whole interface into a client side application. I argued against it and I still would.

Look at what the server was already doing for free. Session authentication with a firewall and role checks per route. Form rendering with CSRF tokens, validation errors rendered next to the field that caused them, and the same constraints enforced on the server where it matters. Flash messages. Pagination. A print view that accountants actually use. Every one of those is a week of work to reproduce on the client, and reproducing them buys nothing for thirty five of the forty routes, which are lists and detail pages that were perfectly fine.

The three screens that hurt were the ones with genuinely stateful editing: an order form where adding a line has to recalculate totals, a filter panel over a table, and a search box that reloaded the entire page for every query. So Vue went in as three small islands inside pages that Symfony still renders, and nothing else changed. No router, no store, no API layer beyond two endpoints that already existed.

The version with no build step

The first cut was one script tag. Vue’s installation page documents several builds, and the default file served from the CDN is the full UMD build, which means it contains the compiler as well as the runtime. That matters here: compiling a template in the browser is exactly what you need if your template is the HTML that Twig already printed.

Which is also where the empty div came from. Twig’s output delimiters are {{ }}. Vue’s default text interpolation delimiters are {{ }}. When you write a Vue expression into a Twig template, Twig gets there first, tries to resolve line.quantity from the server side context, and either throws (our twig.yaml sets strict_variables to %kernel.debug%, so in dev it throws) or silently prints nothing.

There are two fixes. Twig has a verbatim tag that marks a section as raw text and stops it being parsed, which works and which I disliked immediately: every Vue island gets wrapped in a Twig tag, and the moment you want one server value inside that block you have to close and reopen it. The other fix is the delimiters option, which changes the interpolation markers for that instance. The API docs are explicit that it only works in the full build with in-browser compilation, which is fine, because in-browser compilation is the whole reason we are here.

We went with delimiters, and the Twig side ended up looking like this:

{# templates/order/edit.html.twig #}
<div id="order-lines"
     data-lines="{{ lines|json_encode|e('html_attr') }}">
  <table class="table">
    <tbody>
      <tr v-for="line in lines" :key="line.id">
        <td>[[ line.sku ]]</td>
        <td>
          <line-quantity v-model="line.quantity"></line-quantity>
        </td>
        <td class="text-right">[[ money(line.quantity * line.unit_price) ]]</td>
      </tr>
    </tbody>
  </table>
  <p>Preview total: [[ money(total) ]]</p>
</div>

And the JavaScript, which at this point was a file included with a plain script tag after the CDN one:

Vue.component('line-quantity', {
  props: {
    value: { type: Number, required: true }
  },
  template: `<input type="number" class="form-control" min="1"
                    :value="value"
                    @input="$emit('input', Number($event.target.value))">`
});

new Vue({
  el: '#order-lines',
  delimiters: ['[[', ']]'],
  data: function () {
    return {
      lines: JSON.parse(document.getElementById('order-lines').dataset.lines)
    };
  },
  computed: {
    total: function () {
      return this.lines.reduce(function (sum, line) {
        return sum + line.quantity * line.unit_price;
      }, 0);
    }
  },
  methods: {
    money: function (amount) {
      return (amount / 100).toFixed(2);
    }
  }
});

Two things about that component are worth pulling out, because they are the whole options API in miniature. Props are, as the props guide puts it, a one-way-down binding: the parent’s value flows in, and the child must not write to it. So the quantity input cannot assign to value. It emits instead. And v-model on a component is sugar: the components guide spells out that v-model on a custom element expands to a value prop plus a listener on an input event, which is why the component binds :value and emits input with the new number. Get that contract right and the parent template stays one line.

The other rule from the same page that I got wrong once: a component’s data must be a function, not an object, so each instance gets its own copy. With a root instance it does not matter. With a component reused in a v-for it matters a lot, and the failure looks like witchcraft.

The no-build version shipped and worked for about three months. What killed it was not performance. It was that the same quantity component existed in two Twig templates, copy pasted, and had drifted. Plus every file was served unminified, our cache busting was a hand edited ?v=4, and nobody could write an import statement.

Moving to Encore

Encore is Symfony’s wrapper around Webpack, currently 0.23.0, installed with the bundle (symfony/webpack-encore-bundle, 1.2.2) through Flex. The config is small enough to read in one go:

// webpack.config.js
var Encore = require('@symfony/webpack-encore');

Encore
    .setOutputPath('public/build/')
    .setPublicPath('/build')

    .addEntry('app', './assets/js/app.js')
    .addEntry('order-edit', './assets/js/order-edit.js')
    .addEntry('shipment-filter', './assets/js/shipment-filter.js')

    .enableSingleRuntimeChunk()
    .enableVueLoader()
    .enableVersioning(Encore.isProduction())
    .enableSourceMaps(!Encore.isProduction())
    .cleanupOutputBeforeBuild()
;

module.exports = Encore.getWebpackConfig();

One entry per screen, which is the direct translation of the islands idea into build config. The Encore setup docs describe addEntry() as the key part: it names an output file and follows every require or import from that file. enableVueLoader() is a single line, and when you restart Encore it prints the exact yarn add command for what is missing, which for us was vue, vue-loader and vue-template-compiler. That third package is a peer dependency of vue-loader and its version has to track the vue version, so both are pinned in package.json and both get bumped together. We are on 2.6.8.

In Twig, the script tags come from the bundle’s helpers:

{% block javascripts %}
    {{ parent() }}
    {{ encore_entry_script_tags('order-edit') }}
{% endblock %}

That function reads entrypoints.json, a file Encore writes on every build, and renders the tags for that entry in the right order. Right order matters because of enableSingleRuntimeChunk(): the Encore changelog records that calling one of the runtime chunk methods became mandatory in 0.21.0, and it produces a runtime.js that has to load before any other bundle. The same changelog notes that 0.22.0 changed the paths inside entrypoints.json to include the leading slash, which is the kind of detail you only care about if you wrote your own reader. We did not. The file looks like this:

{
  "entrypoints": {
    "order-edit": {
      "js": [
        "/build/runtime.js",
        "/build/order-edit.4a1c8f.js"
      ]
    }
  }
}

The hash in that filename comes from enableVersioning(), which we only turn on for production builds. Encore’s versioning docs explain that this also writes a manifest.json mapping original paths to hashed ones, which Symfony’s asset component reads if you point json_manifest_path at it, so asset('build/images/logo.png') keeps working. Our deploy stopped needing the query string hack the day that went in.

One consequence I did not expect. Once templates live in .vue files, vue-loader compiles them at build time, the compiler is no longer needed in the browser, and the runtime-only build takes over. The installation docs put runtime-only at roughly 30% lighter than the full build. It also means delimiters stops being available, because there is nothing left to compile at runtime. Every island still mounts onto an element that Twig printed, so that root template is still in-DOM and still needs the escape, which is why delimiters is still in the code for exactly those three root instances and nowhere else. Half a cheer.

The state that now lives in two places

Here is the part I have not solved. The order lines exist twice: once as a JSON blob in a data attribute rendered by Twig, and once as reactive state inside Vue. And the arithmetic over them exists twice too, in PHP for the version that gets saved and in JavaScript for the version you see while typing.

They disagreed in production within a fortnight. PHP rounded tax per line and the JavaScript rounded the summed total, so a twelve line order showed a preview total one centavo off the figure the server wrote. Nobody lost money, and the person who noticed was our finance lead, which is worse.

The fix was to stop pretending the client number is authoritative. Anything Vue computes is now labelled a preview, the real totals come back from the server on save, and the two endpoints that Vue talks to return the recalculated document so the component can replace its state wholesale instead of patching it. The money math lives in one PHP service and the JavaScript does a deliberately naive approximation of it. I would rather have the duplication visible and labelled than clever and silently wrong.

The data attribute is the other smell. It works, it is one HTTP request fewer, and |json_encode|e('html_attr') is safe. But it means the initial state of the page is defined in a Twig template, and a developer looking at the Vue component has no way to know its shape without going and reading the controller. If I were starting again I would probably fetch the initial state over the API on mount and accept the flash of an empty table, purely so the contract lives in one place.

What I would keep

The islands decision, without hesitation. Five months in, the server still owns authentication, forms, validation and the print view, and three screens are pleasant to use. The rewrite would still not be finished.

What is still missing: there are no tests on the Vue side at all, because wiring a JavaScript test runner into a project whose CI only knows how to run PHPUnit has never made it to the top of the list. Hot module replacement, which the Encore Vue docs get working with yarn encore dev-server --hot, does not work for us because the dev server port is not exposed out of our container setup, so we run yarn encore dev --watch and refresh like it is 2015. And the build is 14 seconds for three entries, which is fine now and will not be fine at ten.

Next thing I want to try is one .vue file for the shipment filter that does its own fetch on mount, so that at least one of the three islands has no Twig-supplied state at all. If that reads better than the data attribute version, the other two follow.

Sources

  • Vue 2 installation guide: the build matrix, what full and runtime-only mean, why in-DOM templates need the compiler, and the roughly 30% size difference.
  • Vue API: delimiters: changing the interpolation markers, and the restriction that it only applies to in-browser compilation.
  • Vue components guide: data as a function, $emit, and what v-model on a component expands to.
  • Vue props guide: props as a one-way-down binding, which is why the quantity input emits instead of assigning.
  • Encore: setting up your project: addEntry(), the Twig helpers, and what entrypoints.json is for.
  • Encore: enabling Vue.js: enableVueLoader() and the dev-server --hot option we cannot use yet.
  • Encore: asset versioning: enableVersioning(), manifest.json, and the json_manifest_path setting.
  • Encore CHANGELOG: the mandatory runtime chunk call from 0.21.0 and the leading slash change in entrypoints.json in 0.22.0.