Symfony UX Vue.js with AssetMapper

In the previous topic we have introduced Symfony AssetMapper and Import Maps, this topic shows how to use Vue 3 using Symfony UX Vue and AssetMapper.

  • No npm
  • No Webpack
  • No Vite
  • No .vue files

Understanding Symfony UX Vue vs Traditional Vue

Symfony UX Vue is not the same as a traditional Vue application.

In a typical Vue project, Vue controls the entire page with a single root instance. With Symfony UX Vue, you embed isolated Vue components into server-rendered Twig templates. Each vue_component() creates an independent Vue instance — they don't share state by default.

Think of it as "Vue islands" in a sea of server-rendered HTML. Use it to add interactivity to specific parts of your page (e.g. widgets, counters) without rewriting your entire frontend.


Requirements

AssetMapper and import maps must be enabled in your project (see previous topic for setup instructions).


1. Install Symfony UX Vue

Open your project, click Tools -> Composer packages, add the package symfony/ux-vue.

Generate scripts, then install the recipe:

php bin/console app:recipes:install symfony/ux-vue

The recipe automatically configures the following:

config/bundles.php

The Vue bundle is registered in your application:

Symfony\UX\Vue\VueBundle::class => ['all' => true],

assets/app.js

The recipe adds the Vue component registration code:

import { registerVueControllerComponents } from '@symfony/ux-vue';
registerVueControllerComponents();

This setup provides:

  • Vue controllers are auto-discovered from the assets/vue/controllers/ directory
  • Import maps are used to load Vue and its dependencies
  • No build step is required — everything runs directly in the browser
  • No global Vue app instance — each component is mounted independently via Stimulus

2. Create a Vue controller (AssetMapper style)

AssetMapper does not support .vue single-file components because they require a compilation step. Instead, you write Vue components as plain JavaScript files using the template property.

Create a .js file for your component:

assets/vue/controllers/Hello.js

export default {
    props: {
        name: String
    },
    template: `
        <div>
            Hello {{ name }}
        </div>
    `
};

Rules for Vue controllers:

  • The directory must be assets/vue/controllers/ — Symfony only scans this location
  • The filename defines the component name (e.g., Hello.js becomes Hello)
  • You must use export default to export the component object

3. Clear cache after adding controllers (IMPORTANT)

Symfony builds a static map of Vue controllers at cache warmup time. This means the framework needs to rebuild the cache whenever you add, rename, or remove Vue controller files.

Development

When you add, rename, or remove Vue controllers, clear the cache:

php bin/console cache:clear

Production

For production deployments, clear the cache and compile assets:

php bin/console cache:clear --env=prod
php bin/console asset-map:compile --env=prod

If you skip this step, you may encounter the following error:

Vue controller "Hello" does not exist. Possible values: none


4. Render the component in Twig

Use the vue_component() Twig function to render your Vue component:

<div {{ vue_component('Hello', { name: 'Symfony' }) }}></div>

The first argument is the component name, which maps directly to the filename:

Hello.js → Hello

The second argument is an optional object containing props to pass to the component.


5. How it works

Understanding the underlying mechanism helps with debugging:

  • AssetMapper scans the assets/vue/controllers/ directory during cache warmup
  • Symfony builds a static controller map containing all discovered components
  • The registerVueControllerComponents() function exposes this map to the browser
  • When a vue_component() element appears in the DOM, a Stimulus controller mounts the corresponding Vue component

No runtime scanning happens — the component list is fixed at cache build time, which is why clearing the cache is essential when adding new components.


6. Another Example: Interactive Counter

Here's a more complete example demonstrating the Vue 3 Composition API with reactive state and multiple methods.

assets/vue/controllers/Counter.js

import { ref } from 'vue';

export default {
    props: {
        initialCount: {
            type: Number,
            default: 0
        },
        step: {
            type: Number,
            default: 1
        }
    },

    setup(props) {
        // Create a reactive reference for the count value
        const count = ref(props.initialCount);

        // Define methods to modify the count
        const increment = () => {
            count.value += props.step;
        };

        const decrement = () => {
            count.value -= props.step;
        };

        const reset = () => {
            count.value = props.initialCount;
        };

        // Return reactive state and methods for use in the template
        return {
            count,
            increment,
            decrement,
            reset
        };
    },

    template: `
        <div class="counter">
            <span class="counter-value">{{ count }}</span>
            <div class="counter-buttons">
                <button @click="decrement">-</button>
                <button @click="reset">Reset</button>
                <button @click="increment">+</button>
            </div>
        </div>
    `
};

Twig usage

You can render the counter with default props or customize its behavior:

{# Default: starts at 0, step of 1 #}
<div {{ vue_component('Counter') }}></div>

{# Custom initial value and step #}
<div {{ vue_component('Counter', { initialCount: 10, step: 5 }) }}></div>

7. Using Child Components

You can create reusable child components and import them into your controller components. Child components live outside the controllers directory since they're not meant to be rendered directly from Twig.

assets/vue/components/Button.js

export default {
    props: {
        label: String,
        variant: {
            type: String,
            default: 'primary'
        }
    },

    template: `
        <button :class="'btn btn-' + variant" @click="$emit('click')">
            {{ label }}
        </button>
    `
};

assets/vue/controllers/Counter.js (using child component)

Import the child component and register it in the components option:

import { ref } from 'vue';
import Button from '../components/Button.js';

export default {
    components: { Button },

    props: {
        initialCount: {
            type: Number,
            default: 0
        }
    },

    setup(props) {
        const count = ref(props.initialCount);

        return {
            count,
            increment: () => count.value++,
            decrement: () => count.value--
        };
    },

    template: `
        <div class="counter">
            <span>{{ count }}</span>
            <Button label="-" variant="danger" @click="decrement" />
            <Button label="+" variant="success" @click="increment" />
        </div>
    `
};

Note: Only files in assets/vue/controllers/ are registered as Twig-renderable components. Child components placed in other directories (like assets/vue/components/) must be imported manually and cannot be used with vue_component() directly.


8. Lifecycle Events

You can hook into Vue component lifecycle events from your JavaScript code. This is useful for registering global plugins, adding directives, or debugging component behavior.

Add event listeners in your assets/app.js file:

// assets/app.js

// Called before a Vue component is mounted — use this to configure the Vue app instance
document.addEventListener('vue:before-mount', (event) => {
    const { componentName, component, props, app } = event.detail;
    // Register plugins, global components, or directives here
    // Example: app.use(myPlugin);
});

// Called after a Vue component has been mounted to the DOM
document.addEventListener('vue:mount', (event) => {
    const { componentName, component, props } = event.detail;
    console.log(`${componentName} mounted with props:`, props);
});

// Called when a Vue component is unmounted from the DOM
document.addEventListener('vue:unmount', (event) => {
    const { componentName, props } = event.detail;
    console.log(`${componentName} unmounted`);
});

The vue:before-mount event is particularly useful when you need to register Vue plugins before the component initializes.