Symfony AssetMapper: Goodbye Webpack, Hello Simplicity

What if your frontend build process was just… nothing?

The return to simplicity in frontend development

I’ve spent the last five years maintaining webpack configurations. Five years of npm install taking three minutes. Five years of debugging why my build suddenly broke after updating a single package. Five years of explaining to junior developers why they need to learn an entirely separate ecosystem just to add a JavaScript file to a Symfony project.

Then Symfony 6.3 dropped AssetMapper, and I realized we’d been solving the wrong problem all along.

The promise is almost too good to be true: Write modern JavaScript and CSS with zero build system. No Node.js, no npm, no webpack, no configuration files, no transpilation pipeline. Just PHP and the native features browsers have supported for years.

I was skeptical. You should be too. But after migrating three production applications, I’m convinced this is the future of Symfony frontend development.

The Problem We Created for Ourselves

Let me paint a familiar picture. You start a new Symfony project:

symfony new my-project --webapp

In the “old days” (like… 2023), you’d immediately run:

composer require symfony/webpack-encore-bundle
npm install

And then you’d need to:

  • Install Node.js (specific version, of course)
  • Configure webpack.config.js
  • Set up your package.json
  • Deal with node_modules (hello, 400MB)
  • Configure Babel for transpilation
  • Set up watching in development
  • Configure build for production
  • Explain to your ops team why your Docker image is now 2GB

For what? To use import statements in JavaScript. Features that browsers have supported natively since 2017.

The Webpack Rabbit Hole

Here’s what a typical Encore setup looked like:

// webpack.config.js
const Encore = require("@symfony/webpack-encore");

Encore.setOutputPath("public/build/")
  .setPublicPath("/build")
  .addEntry("app", "./assets/app.js")
  .splitEntryChunks()
  .enableSingleRuntimeChunk()
  .cleanupOutputBeforeBuild()
  .enableBuildNotifications()
  .enableSourceMaps(!Encore.isProduction())
  .enableVersioning(Encore.isProduction())
  .configureBabel((config) => {
    config.plugins.push("@babel/plugin-proposal-class-properties");
  })
  .configureBabelPresetEnv((config) => {
    config.useBuiltIns = "usage";
    config.corejs = 3;
  })
  .enableSassLoader()
  .enablePostCssLoader()
  .autoProvidejQuery();

module.exports = Encore.getWebpackConfig();

This is a simplified example. I’ve seen production configs with 300+ lines.

And then your package.json needed dozens of dependencies:

{
  "devDependencies": {
    "@babel/core": "^7.17.0",
    "@babel/preset-env": "^7.16.0",
    "@symfony/webpack-encore": "^4.0.0",
    "core-js": "^3.21.0",
    "css-loader": "^6.7.0",
    "file-loader": "^6.2.0",
    "node-sass": "^7.0.0",
    "sass-loader": "^12.0.0",
    "webpack": "^5.70.0",
    "webpack-cli": "^4.9.0",
    "webpack-notifier": "^1.15.0"
  }
}

Every single one of these packages brought its own dependencies. The total? 374 packages in node_modules.

Enter AssetMapper: The Radical Simplification

AssetMapper asks a simple question: What if we just… didn’t?

What if we didn’t transpile? What if we didn’t bundle? What if we trusted browsers to do what they’ve been able to do for years?

Here’s the entire setup for a new Symfony project with AssetMapper:

symfony new my-project --webapp

That’s it. No, really. Symfony 6.3+ includes AssetMapper by default.

Your “configuration”? A single PHP file:

// importmap.php
<?php

return [
    'app' => [
        'path' => './assets/app.js',
        'entrypoint' => true,
    ],
];

That’s all you need to start. Want to add a third-party library? One command:

php bin/console importmap:require bootstrap

This downloads Bootstrap’s JavaScript to assets/vendor/bootstrap/, adds it to your importmap, and you’re done. No npm, no package.json, no node_modules.

How It Actually Works

AssetMapper leverages two native browser features that have been around for years:

1. ES Modules

Modern browsers understand import and export natively:

// assets/controllers/hello_controller.js
export default class HelloController {
  connect() {
    console.log("Hello from Stimulus!");
  }
}

No transpilation needed. This works in every browser released since 2017.

2. Import Maps

Import maps tell the browser where to find modules:

<script type="importmap">
  {
    "imports": {
      "app": "/assets/app-a3f8b9c2.js",
      "bootstrap": "/assets/vendor/bootstrap/bootstrap-5.3.0.js",
      "@hotwired/stimulus": "/assets/vendor/@hotwired/stimulus.js"
    }
  }
</script>

This means you can write:

import { Modal } from "bootstrap";

And the browser knows exactly where to find it. No bundler required.

Browser support? 94% of users have native support, and Symfony includes a polyfill for the remaining 6%.

Your First AssetMapper Project

Let’s build something real. We’ll create a Symfony app with Stimulus controllers and Turbo for that SPA-like feel.

Step 1: Install Symfony

symfony new asset-demo --webapp
cd asset-demo

AssetMapper is already configured. Check config/packages/asset_mapper.yaml:

framework:
  asset_mapper:
    paths:
      - assets/

Step 2: Add Dependencies

Let’s add Stimulus and Turbo:

php bin/console importmap:require @hotwired/stimulus
php bin/console importmap:require @hotwired/turbo

These commands:

  1. Download the packages to assets/vendor/
  2. Add them to importmap.php
  3. Lock the versions in importmap.lock

No npm install. No waiting. No 400MB node_modules.

Step 3: Create a Stimulus Controller

// assets/controllers/counter_controller.js
import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
  static targets = ["count"];

  connect() {
    this.count = 0;
    this.updateDisplay();
  }

  increment() {
    this.count++;
    this.updateDisplay();
  }

  updateDisplay() {
    this.countTarget.textContent = this.count;
  }
}

Step 4: Register Your Controller

// assets/app.js
import { Application } from "@hotwired/stimulus";
import CounterController from "./controllers/counter_controller.js";

// Start Stimulus
const application = Application.start();
application.register("counter", CounterController);

Step 5: Use It in Your Template

{# templates/home/index.html.twig #}
{% extends 'base.html.twig' %}

{% block body %}
<div data-controller="counter">
    <p>Count: <span data-counter-target="count">0</span></p>
    <button data-action="click->counter#increment">
        Increment
    </button>
</div>
{% endblock %}

Step 6: Include Assets in Your Base Template

{# templates/base.html.twig #}
<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <title>{% block title %}Welcome!{% endblock %}</title>
        {% block stylesheets %}
            {{ importmap('app') }}
        {% endblock %}
    </head>
    <body>
        {% block body %}{% endblock %}
    </body>
</html>

The importmap('app') function:

  1. Renders the import map
  2. Includes the polyfill for older browsers
  3. Loads your entry point (app.js)
  4. Adds version hashes for cache busting

Run Your App

symfony serve

Visit http://localhost:8000 and click the button. It works. With zero configuration, zero build time, and zero Node.js.

Real-World Usage: Adding Bootstrap

Let’s add Bootstrap 5 with custom Sass:

php bin/console importmap:require bootstrap

For the CSS, we need Sass support. Install the Sass bundle:

composer require symfonycasts/sass-bundle

Now create your custom stylesheet:

// assets/styles/app.scss
// Customize Bootstrap variables
$primary: #007bff;
$border-radius: 0.5rem;

// Import Bootstrap
@import "bootstrap/scss/bootstrap";

// Your custom styles
.hero {
  background: linear-gradient(135deg, $primary, darken($primary, 15%));
  color: white;
  padding: 3rem;
  border-radius: $border-radius;
}

Import it in your JavaScript:

// assets/app.js
import "./styles/app.scss";
import { Application } from "@hotwired/stimulus";
import { Modal, Dropdown } from "bootstrap";

// Make Bootstrap components available globally if needed
window.bootstrap = { Modal, Dropdown };

// Initialize Stimulus...

The Sass bundle compiles your SCSS on-the-fly in development and pre-compiles for production.

Migration from Webpack Encore

Got an existing Encore project? Here’s the migration path.

What You Can Migrate Easily

  • Stimulus controllers: Work as-is
  • Turbo: Drop-in replacement
  • Vanilla JavaScript: Usually works unchanged
  • CSS/Sass: Works with sass-bundle
  • Static assets: Images, fonts, etc.

What Requires a Build Step (Keep Encore)

  • React components: Need JSX transpilation
  • Vue single-file components: Need .vue compilation
  • TypeScript: Need transpilation (though there’s now a TypeScript bundle)

Migration Steps

1. Audit Your Assets

# What are you actually using?
ls assets/

If it’s mostly Stimulus controllers and CSS, you’re golden for migration.

2. Create a New Branch

git checkout -b migrate-to-assetmapper

3. Install AssetMapper

composer require symfony/asset-mapper symfony/stimulus-bundle

4. Remove Encore

composer remove symfony/webpack-encore-bundle
rm webpack.config.js package.json package-lock.json
rm -rf node_modules/

5. Map Your Assets

# config/packages/asset_mapper.yaml
framework:
  asset_mapper:
    paths:
      - assets/
    excluded_patterns:
      - "*/tests/*"
      - "*.map"

6. Convert Dependencies

For each package in your package.json, run:

php bin/console importmap:require package-name

Important gotcha: If you imported specific files from a package, you need to require those files:

# Old Encore way
# import Routing from 'fos-js-routing-bundle';

# AssetMapper way - require the specific file
php bin/console importmap:require fos-js-routing-bundle/routing.js

7. Update Templates

{# Old Encore way #}
{{ encore_entry_link_tags('app') }}
{{ encore_entry_script_tags('app') }}

{# New AssetMapper way #}
{{ importmap('app') }}

8. Update Your Entry Point

// assets/app.js
// Remove Encore-specific stuff like:
// import './bootstrap.js'; // If it just started Stimulus

// Keep your actual application code
import { Application } from "@hotwired/stimulus";
import "./styles/app.scss";

const application = Application.start();
// Register controllers...

9. Handle Images and Fonts

{# Old way #}
<img src="{{ asset('build/images/logo.png') }}">

{# New way #}
<img src="{{ asset('images/logo.png') }}">

AssetMapper automatically versions all files in your assets/ directory.

10. Test Everything

symfony serve

Click around. Check the browser console. Most things just work.

Common Migration Issues

Issue: Module not found

Failed to resolve module specifier "lodash"

Solution: You forgot to importmap:require it:

php bin/console importmap:require lodash

Issue: Can’t import specific function

// This might not work
import { debounce } from "lodash";

Solution: Import the specific file:

php bin/console importmap:require lodash-es

Then:

import { debounce } from "lodash-es";

Production Deployment

When you’re ready to deploy, compile your assets:

php bin/console asset-map:compile

This:

  1. Copies all assets to public/assets/ with versioned filenames
  2. Generates manifest.json for fast lookups
  3. Pre-renders the import map for maximum performance

Your public/assets/ directory will look like:

public/assets/
├── app-a3f8b9c2.js
├── controllers/
│   └── counter_controller-d8e7f6a1.js
├── vendor/
│   └── @hotwired/
│       └── stimulus-e9f0a8b7.js
├── manifest.json
└── importmap.json

Add this to your .gitignore:

/public/assets/

And to your deployment script:

php bin/console asset-map:compile

Docker Deployment

Your Dockerfile becomes beautifully simple:

FROM php:8.3-fpm

# Install PHP extensions
RUN docker-php-ext-install pdo pdo_mysql

# Copy application
WORKDIR /app
COPY . .

# Install PHP dependencies
RUN composer install --no-dev --optimize-autoloader

# Compile assets (no Node.js needed!)
RUN php bin/console asset-map:compile

# That's it
CMD ["php-fpm"]

No Node.js base image. No npm install. No webpack build. The image is 60% smaller.

Performance Considerations

The Good News

  • Zero build time in development: Change a file, refresh, see changes
  • Smaller Docker images: No Node.js, no node_modules
  • Browser caching: Each file has its own hash, cache them forever
  • Parallel downloads: Browser can request modules in parallel
  • Native browser features: No runtime overhead from bundler code

The Reality Check

AssetMapper makes different tradeoffs than webpack:

More HTTP requests: Instead of 1 big app.js bundle, you might have 20 smaller files.

Is this bad? Not really. With HTTP/2, parallel requests are cheap. And you’re only downloading what changed between deploys, not re-downloading entire bundles.

No tree shaking: You import the whole library, not just the parts you use.

// Webpack can tree-shake this to just debounce
import { debounce } from "lodash";

// AssetMapper loads all of lodash
import { debounce } from "lodash-es";

Mitigation: Use packages that are already modular. Bootstrap 5, for example, lets you import just the components you need:

// Only load Modal, not all of Bootstrap
import { Modal } from "bootstrap";

Real-World Performance

I migrated a production app with:

  • 30 Stimulus controllers
  • Bootstrap 5
  • Custom Sass
  • 15 third-party libraries

Before (Webpack Encore):

  • Initial load: 487 KB (1 JavaScript bundle, 1 CSS file)
  • Build time: 12 seconds
  • Docker image: 1.2 GB

After (AssetMapper):

  • Initial load: 423 KB (28 JavaScript files, 1 CSS file)
  • Build time: 1.8 seconds
  • Docker image: 512 MB

The page actually loaded faster because:

  1. Smaller total bytes (no bundler overhead)
  2. Better caching (changing one controller doesn’t invalidate everything)
  3. Browser can prioritize critical modules

The TypeScript Question

“But I need TypeScript!” I hear you say.

Good news: There’s a TypeScript bundle for AssetMapper.

composer require sensiolabs/typescript-bundle

It transpiles TypeScript files on-the-fly in development and pre-compiles for production. You write:

// assets/controllers/typed_controller.ts
import { Controller } from "@hotwired/stimulus";

interface CounterValue {
  count: number;
  timestamp: Date;
}

export default class extends Controller<HTMLDivElement> {
  static targets = ["count"];

  declare readonly countTarget: HTMLElement;
  private value: CounterValue;

  connect(): void {
    this.value = { count: 0, timestamp: new Date() };
    this.updateDisplay();
  }

  increment(): void {
    this.value.count++;
    this.value.timestamp = new Date();
    this.updateDisplay();
  }

  private updateDisplay(): void {
    this.countTarget.textContent = this.value.count.toString();
  }
}

It just works. Type checking, autocompletion, the whole deal.

When NOT to Use AssetMapper

Let’s be honest about the limitations.

Stick with Encore/Vite if you’re using:

React with JSX

// This needs transpilation
function App() {
  return <div>Hello World</div>;
}

Vue Single-File Components

<template>
  <div>{{ message }}</div>
</template>

<script>
export default {
  data() {
    return { message: "Hello Vue!" };
  },
};
</script>

Heavy optimization requirements: If you’re building a public-facing site with millions of users and you need every last KB optimized, webpack’s tree-shaking and code-splitting might be worth the complexity.

But consider this:

Most Symfony apps are:

  • Internal admin panels
  • B2B applications
  • API backends with minimal frontend
  • Progressive enhanced websites

For these? AssetMapper is perfect. The simplicity pays dividends every single day.

The Bigger Picture: Less is More

AssetMapper represents a philosophical shift. We spent a decade adding layers of complexity to solve problems that browsers were going to solve anyway.

We transpiled ES6 to ES5… until browsers supported ES6. We bundled modules… until browsers supported ES modules. We built complex build pipelines… until we realized we didn’t need them.

AssetMapper is Symfony saying: “Let’s trust the platform.”

It’s the same philosophy that led to:

  • Using native CSS variables instead of Sass variables
  • Using native fetch() instead of jQuery.ajax
  • Using native <dialog> instead of modal libraries

The platform is good enough now. Let’s use it.

Getting Started Today

Here’s your action plan:

For new projects:

symfony new my-project --webapp

You’re already using AssetMapper. Just start building.

For existing projects:

  1. Create a small experimental app with AssetMapper
  2. Get comfortable with the workflow
  3. Migrate a low-risk existing project
  4. Evaluate the results
  5. Make a decision based on evidence, not fear

The Alternatives (And Why I Chose AssetMapper)

Webpack Encore

Pros:

  • Mature, well-documented
  • Full control over build process
  • Excellent tree-shaking
  • Works with React/Vue

Cons:

  • Requires Node.js ecosystem
  • Complex configuration
  • Slow build times
  • Large dependency tree
  • Debugging webpack is a special kind of hell

Verdict: Still the right choice for React/Vue apps, but overkill for Stimulus-based projects.

Vite

Pros:

  • Blazingly fast in development
  • Better DX than webpack
  • Modern and actively developed
  • Great for SPAs

Cons:

  • Still requires Node.js
  • Adds another tool to learn
  • Yet another config file
  • Overkill for Symfony’s typical use cases

Verdict: Great tool, but solving problems that AssetMapper sidesteps entirely.

AssetMapper

Pros:

  • Zero configuration
  • No Node.js required
  • Instant refresh in development
  • PHP-native workflow
  • Smaller Docker images
  • Perfect for Stimulus/Turbo

Cons:

  • No tree-shaking
  • Can’t use React JSX or Vue SFCs
  • More HTTP requests (mitigated by HTTP/2)
  • Newer, less battle-tested

Verdict: The pragmatic choice for modern Symfony applications that don’t need a heavy frontend framework.

Conclusion: Simplicity Won

Three days. That’s how long I spent last month debugging a webpack configuration that broke after updating one dependency. Three days of reading GitHub issues, trying different loader configurations, and slowly losing my sanity.

With AssetMapper, the equivalent problem would have been: Update the importmap entry. Done.

The future of Symfony frontend development isn’t more complexity. It’s less. It’s trusting browsers to do their job. It’s embracing the platform instead of fighting it.

Is AssetMapper perfect? No. Will it work for every project? Also no.

But for the 80% of Symfony projects that just need to add some interactivity to server-rendered pages? It’s transformative.

No build step. No Node.js. No webpack. No problem.

Give it a try. Your future self will thank you when you’re not debugging webpack configurations at 3 AM.


Resources:

What’s your webpack horror story? Share it in the comments - misery loves company, and I’d love to hear I’m not alone in this.