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:
- Download the packages to
assets/vendor/ - Add them to
importmap.php - 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:
- Renders the import map
- Includes the polyfill for older browsers
- Loads your entry point (
app.js) - 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:
- Copies all assets to
public/assets/with versioned filenames - Generates
manifest.jsonfor fast lookups - 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:
- Smaller total bytes (no bundler overhead)
- Better caching (changing one controller doesn’t invalidate everything)
- 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:
- Create a small experimental app with AssetMapper
- Get comfortable with the workflow
- Migrate a low-risk existing project
- Evaluate the results
- 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:
- Official AssetMapper Documentation
- AssetMapper Component on GitHub
- Upgrading to AssetMapper Guide
- Import Maps Browser Support
- ES Module Shims Polyfill
- TypeScript Bundle for AssetMapper
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.