1. Introduction
2. Before You Begin: Think About Your Frontend
2.1. AssetMapper vs Webpack Encore
This documentation helps you decide when to use AssetMapper or Webpack Encore when building Symfony web applications.
2.1.1. Starting a New Project
You can always start with the Symfony skeleton by running:
symfony new blog
#or
composer create-project symfony/skeleton my_project_name
but it’s important to know what you want to achieve. If you run:
symfony new my_project_name --webapp
#or
composer create-project symfony/webapp-pack my_project_name
You will by default be using AssetMapper.
The --webapp flag sets up:
-
Stimulus and Turbo via
symfony/ux -
JavaScript/CSS asset management via AssetMapper (no Node.js needed)
In order to install use Tailwind:
composer require symfonycasts/tailwind-bundle
php bin/console tailwind:init
php bin/console tailwind:build -w
|
Warning
|
If you are developing on an Apache server, Tailwind CSS will have no visible effect,
and the Symfony Debug Profiler may not be displayed correctly on the frontend until you create a proper Use the following
This enables URL rewriting so that Symfony can handle frontend routing and assets properly. |
2.1.2. When AssetMapper Is Enough
Stick with AssetMapper if:
-
You use plain JavaScript or Stimulus controllers
-
You don’t use JS frameworks
-
You are happy with Tailwind via
symfonycasts/tailwind-bundle -
You prefer simpler, build-free setups
2.1.3. Switching to Webpack Encore
If you need to use Webpack Encore instead:
composer remove symfony/asset-mapper
composer require symfony/webpack-encore-bundle
npm install
Then create webpack.config.js and configure the assets/ directory.
2.1.4. Comparison Table
| Installation Option | JavaScript/CSS Management |
|---|---|
|
✅ AssetMapper |
Manual Encore setup |
🔧 Webpack Encore |
2.1.5. When You Need Webpack Encore
Use Webpack Encore when:
-
You use libraries that require Node.js, such as:
-
Vue.js
-
React
-
TypeScript
-
Bootstrap (JS)
-
Any
npmlibrary usingimport,require, or transpilation
-
-
You need features like:
-
Babel
-
SCSS/SASS/LESS
-
PostCSS
-
Code splitting, hot reload, or tree shaking
-
Advanced asset compression or fingerprinting
-
-
You have complex or legacy frontend tooling
2.1.6. Summary Table
| Requirement | Use Webpack Encore? |
|---|---|
Vue / React / TypeScript |
✅ Yes |
SCSS, PostCSS |
✅ Yes |
Tailwind via symfonycasts bundle |
❌ No (AssetMapper OK) |
Simple JS + Stimulus |
❌ No |
Using |
✅ Yes |
3. Install Symfony
3.1. Install Symfony sekelton
-
Install symfony-cli (Linux Debian)
curl -1sLf 'https://dl.cloudsmith.io/public/symfony/stable/setup.deb.sh' | sudo -E bash && sudo apt install symfony-cli -
Check requirements
symfony check:req -
create symfony project
symfony new blogThis will give us a tiny project with only the base things installed. later we can use "symfony new blog --webapp" to install all packages at once.
To have a look on the file structure:
cd blog git ls-files -
Install Twig and symfony/maker-bundle and create HomeController
composer require symfony/twig-bundle composer require symfony/maker-bundle --dev php bin/console make:controller HomeController -
In src/Controller/HomeController change
[Route('/home', name: 'app_home')]to[Route('/', name: 'app_home')]:final class HomeController extends AbstractController { #[Route('/', name: 'app_home')] public function index(): JsonResponse { return $this->json([ 'message' => 'Welcome to your new controller!', 'path' => 'src/Controller/HomeController.php', ]); } }
3.2. Apache Compatibility: Tailwind CSS & Symfony Debug Profiler
When developing a Symfony application on an Apache web server, you might notice that:
-
Tailwind CSS has no visible effect
-
The Symfony Debug Profiler does not appear correctly in the frontend
3.2.1. Cause
Without proper URL rewriting, Apache cannot correctly serve static assets (like Tailwind CSS files) or internal Symfony tools. In development mode, these assets are dynamically handled by Symfony, but Apache still needs to route the requests properly.
3.2.2. Solution
Create a .htaccess file in your public/ directory with the following content:
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^(.*)$ index.php [QSA,L]
</IfModule>
3.2.3. Install Tailwind CSS and Symfony Profiler
Install Symfony Profiler:
composer require --dev symfony/profiler-pack
Install Tailwind CSS with AssetMapper (if you’re not using Node.js):
composer require symfonycasts/tailwind-bundle
php bin/console tailwind:init
php bin/console tailwind:build -w
Make sure Tailwind is initialized in your CSS file (e.g. app.css):
@tailwind base;
@tailwind components;
@tailwind utilities;
3.2.4. After that
Reload your browser (clear the cache if necessary), and confirm that Tailwind CSS and the Symfony Debug Profiler are working as expected.
3.3. Symfony Setup Script (with Tailwind CSS & Profiler)
This setup will create a minimal Symfony project, configure it for Apache, and install Tailwind CSS and the Symfony Profiler.
-
Step 1: Create the
setup.shFileCreate a new file named
setup.shand paste the following content into it:#!/bin/bash set -e # Check if project name is given if [ -z "$1" ]; then echo "❌ Usage: $0 <project_name>" exit 1 fi PROJECT_NAME="$1" # Define the environment variables ENV_FILE=".env.dev" declare -A ENV_VARS ENV_VARS["DATABASE_URL"]="mysql://USER:PASSWORD@MYSQL_HOST:3306/${PROJECT_NAME}" ENV_VARS["MAILER_DSN"]="mail" # 1. Create new Symfony project (minimal) using Composer composer create-project symfony/skeleton "$PROJECT_NAME" # 2. Change into project directory cd "$PROJECT_NAME" || exit 1 # 3. Require Twig and MakerBundle, then generate HomeController composer require symfony/twig-bundle composer require symfony/maker-bundle --dev # 4. Generate HomeController php bin/console make:controller HomeController <<< "" # 5. Replace route in HomeController sed -i "s|Route('/home'|Route('/'|g" src/Controller/HomeController.php # 6. Create .htaccess in public/ for Apache compatibility cat <<'EOF' > public/.htaccess <IfModule mod_rewrite.c> RewriteEngine On RewriteCond %{REQUEST_FILENAME} !-f RewriteRule ^(.*)$ index.php [QSA,L] </IfModule> EOF # 7. Install Symfony Profiler, orm, form, validator, security-csrf composer require --dev symfony/profiler-pack composer require symfony/orm-pack composer require symfony/form composer require symfony/validator composer require symfony/security-csrf # 8. Install and initialize Tailwind CSS composer require symfonycasts/tailwind-bundle php bin/console tailwind:init # 9. Inject custom environment variables into .env.dev (without deleting existing content) touch "$ENV_FILE" for key in "${!ENV_VARS[@]}"; do value="${ENV_VARS[$key]}" if grep -q "^$key=" "$ENV_FILE"; then sed -i "s|^$key=.*|$key=\"$value\"|" "$ENV_FILE" else echo "$key=\"$value\"" >> "$ENV_FILE" fi done # 10. Create Database php bin/console doctrine:database:create # 10. Commit git init git status git add . git commit -m "first commit" # 11. Start Tailwind in watch mode echo "✅ Setup complete. Tailwind is now watching for changes..." php bin/console tailwind:build -w -
Step 2: Make the Script Executable
chmod +x setup.sh -
Step 3: Run the Script
Replace
my_projectwith your desired project name:./setup.sh my_project -
Minimal Symfony base.html.twig with Tailwind and AssetMapper
The following modifications were applied exclusively within the
<body>section of the HTML template:-
Added Tailwind base classes to
<body><body class="font-sans bg-gray-50 text-gray-900">This applies a basic sans-serif font, a light gray background, and dark text color using Tailwind CSS utility classes.
-
Inserted a minimal, non-responsive navigation bar
<nav class="bg-gray-800 text-white p-4 flex items-center space-x-8"> <ul class="flex space-x-6"> <li><a href="#" class="hover:underline">Home</a></li> <li><a href="#" class="hover:underline">Artikel</a></li> <li><a href="#" class="hover:underline">Contact</a></li> </ul> </nav>This creates a dark header bar with three inline links, each styled using Tailwind CSS classes.
-
Wrapped the content block in a
<main>with padding<main class="p-4"> {% block body %}{% endblock %} </main>This adds padding around the main content area to improve layout spacing.
-
Complete
base.html.twigTemplate<!-- base.html.twig --> <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>{% block title %}Welcome!{% endblock %}</title> <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text><text y=%221.3em%22 x=%220.2em%22 font-size=%2276%22 fill=%22%23fff%22>sf</text></svg>"> {% block javascripts %} {% block importmap %}{{ importmap('app') }}{% endblock %} {% endblock %} </head> <body class="font-sans bg-gray-50 text-gray-900"> <!-- Minimal Navigation --> <nav class="bg-gray-800 text-white p-4 flex items-center space-x-8"> <ul class="flex space-x-6"> <li><a href="#" class="hover:underline">Home</a></li> <li><a href="#" class="hover:underline">Artikel</a></li> <li><a href="#" class="hover:underline">Contact</a></li> </ul> </nav> <main class="p-4"> {% block body %}{% endblock %} </main> </body> </html> -
Updated Navigation Bar with Logo (via AssetMapper)
Place your logo image at:
assets/images/logo.jpgThis version of the navigation bar includes a logo image loaded through AssetMapper. The logo is placed at the left side of the navigation bar.
<nav class="bg-gray-800 text-white p-4 flex items-center space-x-8"> <a href="#"> <img src="{{ asset('images/logo.jpg') }}" alt="Logo" class="h-8"> </a> <ul class="flex space-x-6"> <li><a href="#" class="hover:underline">Home</a></li> <li><a href="#" class="hover:underline">Artikel</a></li> <li><a href="#" class="hover:underline">Contact</a></li> </ul> </nav>Back to Table of ContentsNoteIf you update the template and refresh the page now, you’ll encounter an error — because even when using only Symfony’s AssetMapper (without Webpack or manual
public/files), theasset()Twig function is still required to resolve asset URLs.To fix this, make sure the
symfony/assetpackage is installed:composer require symfony/asset
-
3.4. Symfony 7 Blog: Entity, CRUD Controller
If you’ve already installed the core bundles mentioned in the previous section, you are ready to create you first Entity with CRUD functionality:
-
creat Entity
php bin/console make:entity Article title: Varchar(255) content: Text image: Varchar(255), nullable (stores article image filename) createdAt: datetime_immutable updatedAt: datetime_immutable -
Make migration
php bin/console make:migration php bin/console doctrine:migrations:migrate -
creat CRUD Controller
php bin/console make:crud ArticleWhat gets generated automatically?
File Description src/Controller/ArticleController.phpCRUD controller for the
Articleentitysrc/Form/ArticleForm.phpForm class based on the entity fields
templates/article/*.html.twigTwig templates for all views (
index,new,edit,show)Important
The generator reads the fields directly from your
Articleentity and creates:-
Form fields like
title,content,image, etc. -
Twig templates using
form_row(form.title)and so on -
Controller logic for persisting, editing, deleting, etc.
Conclusion
You only need to make sure your
Articleentity exists and is complete before running themake:crudcommand — otherwise, Symfony will throw an error. Afterward, you can manually customize the generated files, for example by adding an image upload field as we discussed. If you want, I can show you a complete example of whatArticleForm.phpshould look like with image upload support. -
Add Automatic Timestamps in
Article.phpAdd the following code to your
Articleentity class to automatically setcreatedAtandupdatedAtvalues:// src/Form/ArticleForm.php --- use Doctrine\ORM\Event\PrePersistEventArgs; use Doctrine\ORM\Event\PreUpdateEventArgs; --- #[ORM\HasLifecycleCallbacks] class Article { --- #[ORM\PrePersist] public function setCreatedAtValue(): void { $now = new \DateTimeImmutable(); $this->createdAt = $now; $this->updatedAt = $now; } #[ORM\PreUpdate] public function setUpdatedAtValue(): void { $this->updatedAt = new \DateTimeImmutable(); } }ImportantMake sure to include the
#[ORM\HasLifecycleCallbacks]attribute at the class level. It’s required for the automatic methods to be triggered during entity lifecycle events.Remove or comment out the createdAt and updatedAt fields from the form to prevent them from being displayed or edited manually.
public function buildForm(FormBuilderInterface $builder, array $options): void { $builder ->add('title') ->add('content') ->add('image') // ->add('createdAt', null, [ // 'widget' => 'single_text', // ]) // ->add('updatedAt', null, [ // 'widget' => 'single_text', // ]) ; }
-
-
Minimal Tailwind design
// templates/article/index.html.twig {% extends 'base.html.twig' %} {% block title %}Article index{% endblock %} {% block body %} <div class="max-w-6xl mx-auto p-4"> <h1 class="text-2xl font-bold mb-4">Article index</h1> <table class="w-full table-auto border border-gray-300 mb-6"> <thead class="bg-gray-100"> <tr> <th class="p-2 border">Id</th> <th class="p-2 border">Title</th> <th class="p-2 border">Content</th> <th class="p-2 border">Image</th> <th class="p-2 border">CreatedAt</th> <th class="p-2 border">UpdatedAt</th> <th class="p-2 border">Actions</th> </tr> </thead> <tbody> {% for article in articles %} <tr> <td class="p-2 border">{{ article.id }}</td> <td class="p-2 border">{{ article.title }}</td> <td class="p-2 border">{{ article.content }}</td> <td class="p-2 border">{{ article.image }}</td> <td class="p-2 border">{{ article.createdAt ? article.createdAt|date('Y-m-d H:i:s') : '' }}</td> <td class="p-2 border">{{ article.updatedAt ? article.updatedAt|date('Y-m-d H:i:s') : '' }}</td> <td class="p-2 border space-x-2"> <a href="{{ path('app_article_show', {'id': article.id}) }}" class="bg-blue-600 text-white px-3 py-1 rounded text-sm">Show</a> <a href="{{ path('app_article_edit', {'id': article.id}) }}" class="bg-yellow-500 text-white px-3 py-1 rounded text-sm">Edit</a> </td> </tr> {% else %} <tr> <td colspan="7" class="p-2 border text-center text-gray-500">No records found</td> </tr> {% endfor %} </tbody> </table> <a href="{{ path('app_article_new') }}" class="bg-green-600 text-white px-4 py-2 rounded">Create new</a> </div> {% endblock %}// templates/article/show.html.twig {% extends 'base.html.twig' %} {% block title %}{{ article.title }}{% endblock %} {% block body %} <div class="max-w-2xl mx-auto p-4"> <h1 class="text-2xl font-bold mb-4">{{ article.title }}</h1> {% if article.image %} <img src="{{ asset('uploads/images/' ~ article.image) }}" alt="{{ article.title }}" class="mb-4 rounded"> {% endif %} <div class="prose mb-6"> {{ article.content|raw }} </div> <p class="text-sm text-gray-500 mb-2"> Created: {{ article.createdAt ? article.createdAt|date('Y-m-d') : '' }} | Updated: {{ article.updatedAt ? article.updatedAt|date('Y-m-d') : '' }} </p> <div class="mt-4 space-x-2"> <a href="{{ path('app_article_index') }}" class="text-blue-600 underline">Back</a> <a href="{{ path('app_article_edit', {'id': article.id}) }}" class="text-blue-600 underline">Edit</a> </div> <div class="mt-2"> {{ include('article/_delete_form.html.twig') }} </div> </div> {% endblock %}// templates/article/new.html.twig {% extends 'base.html.twig' %} {% block title %}New Article{% endblock %} {% block body %} <div class="max-w-2xl mx-auto p-4"> <h1 class="text-2xl font-bold mb-4">Create new Article</h1> {{ include('article/_form.html.twig') }} <a href="{{ path('app_article_index') }}" class="text-blue-600 underline">← Back to list</a> </div> {% endblock %}// templates/article/edit.html.twig {% extends 'base.html.twig' %} {% block title %}Edit Article{% endblock %} {% block body %} <div class="max-w-2xl mx-auto p-4"> <h1 class="text-2xl font-bold mb-4">Edit Article</h1> {{ include('article/_form.html.twig', {'button_label': 'Update'}) }} <div class="mt-4 space-x-2"> <a href="{{ path('app_article_index') }}" class="text-blue-600 underline">← Back to list</a> {{ include('article/_delete_form.html.twig') }} </div> </div> {% endblock %}// templates/article/_form.html.twig {{ form_start(form, {'attr': {'class': 'space-y-4 max-w-xl mx-auto p-4'}}) }} {% for field in form %} {% if field.vars.name != '_token' and field.vars.block_prefixes[1] != 'hidden' %} <div> {{ form_label(field, null, {'label_attr': {'class': 'block mb-1 text-sm font-medium'}}) }} {{ form_widget(field, {'attr': {'class': 'w-full p-2 border border-gray-400 rounded'}}) }} {{ form_errors(field) }} </div> {% endif %} {% endfor %} <button class="bg-blue-600 text-white px-4 py-2 rounded">{{ button_label|default('Save') }}</button> {{ form_end(form) }} -
Meke commit
git status git add . git commit -m "Add Article entity with CRUD, timestamps, and minimal Tailwind UI"
3.4.1. Asset (symfony/asset) vs AssetMapper in Symfony
Symfony offers two main tools to manage frontend assets: symfony/asset and AssetMapper. While both help integrate static files into your Symfony app, they serve different use cases:
symfony/asset is a lightweight helper that generates URLs to files placed in your public/ directory. It doesn’t process or move files—ideal for simple projects where you manage assets manually.
AssetMapper is Symfony’s modern solution for managing an asset pipeline. It compiles, versions, and maps files from the assets/ directory to public/assets/, supporting modern features like ES module imports, SCSS compilation, and automatic versioning—all without needing Node or Webpack.
Use symfony/asset for basic sites. Use AssetMapper for modern frontends that benefit from processing and organizing assets.
The table below provides a clear side-by-side comparison of symfony/asset and AssetMapper, highlighting their differences in purpose, processing, versioning, and typical use cases.
| Feature | symfony/asset | AssetMapper |
|---|---|---|
Purpose |
Generates URLs for static files in |
Manages, compiles, and maps assets from |
Processing |
No processing or compilation |
Compiles JS, CSS, SCSS; resolves imports |
Versioning |
Manual (you handle cache-busting) |
Automatic hash-based filenames |
Build step required |
❌ None |
✅ Yes ( |
Uses |
❌ No |
✅ Yes |
Works with imports |
❌ No |
✅ Yes (ES modules, CSS imports, images, etc.) |
Output location |
You reference files directly in |
Symfony writes processed files into |
Node/Webpack needed |
❌ Not required |
❌ Not required (optional) |
Twig |
✅ Yes ( |
✅ Yes ( |
Use case |
Simple sites needing file links |
Modern apps needing asset pipeline without JS tooling |
Use symfony/asset when you only need simple file URLs from the public/ directory.
Use AssetMapper for modern asset management with automatic compilation, dependency handling, and versioning – all without Node.js or Webpack.
If you’re building a Symfony 6.3+ app with modern frontend needs, AssetMapper is the preferred tool.
-
Why You Still Need
symfony/assetwith AssetMapperEven when using the modern AssetMapper system in Symfony, the
symfony/assetcomponent is still required. Here’s why:Reasons
-
The
asset()Twig function — used like{{ asset('uploads/image.jpg') }}— is provided by thesymfony/assetcomponent. -
AssetMapper handles compilation, versioning, and mapping of files from the
assets/directory, but it delegates URL generation tosymfony/asset. -
Symfony Flex (
--webapp) installssymfony/assetautomatically. -
In minimal setups (
symfony/skeleton), you must install it manually using:composer require symfony/assetSummary
AssetMapper focuses on processing and mapping assets.
-
symfony/asset focuses on generating correct public URLs.
|
Note
|
You need |
3.5. Symfony 7 Blog: Image Upload, Validation, and Multi-Size Rendering
3.5.1. Uploade image (vich/uploader-bundle)
-
If you’ve already installed the core bundles mentioned in the previous section, continue by adding the following packages required for image upload and processing:
composer require vich/uploader-bundle -
VichUploaderBundle Configuration
vich_uploader: db_driver: orm mappings: article_images: uri_prefix: /images/article upload_destination: '%kernel.project_dir%/public/images/article' namer: Vich\UploaderBundle\Naming\SmartUniqueNamer-
db_driver: orm:: Specifies that Doctrine ORM is used for database integration. -
mappings.article_image:: Defines a mapping calledarticle_imageused for file uploads related to an entity. -
uri_prefix: /images/article:: Sets the public URI where uploaded files will be accessible. -
upload_destination: '%kernel.project_dir%/public/images/article':: Sets the filesystem path where the files will be stored. -
namer: Vich\UploaderBundle\Naming\SmartUniqueNamer:: Uses a built-in namer that automatically generates unique filenames to avoid overwriting.This setup is typically used to manage image uploads for an entity like
Article.
-
-
Non-Persistent File Field (used temporarily during the request) for VichUploader in Symfony
This code snippet shows how to add a non-persistent field to an entity (like Article) for file uploads using
VichUploaderBundle.// src/Entity/Article.php use Symfony\Component\HttpFoundation\File\File; use Vich\UploaderBundle\Mapping\Annotation as Vich; #[ORM\Entity(repositoryClass: ArticleRepository::class)] #[Vich\Uploadable] class Article { // ... existing fields #[Vich\UploadableField(mapping: 'article_images', fileNameProperty: 'image')] private ?File $imageFile = null; public function setImageFile(?File $imageFile = null): void { $this->imageFile = $imageFile; if ($imageFile !== null) { $this->updatedAt = new \DateTimeImmutable(); } } public function getImageFile(): ?File { return $this->imageFile; } }-
Explanation of Key Parts
-
#[Vich\Uploadable]:: Marks the entity as uploadable and activates VichUploader features for it. -
private ?File $imageFile = null;:: A non-persistent property used temporarily during the upload process. It is not stored in the database. -
#[Vich\UploadableField(…)]:: Links this temporary$imageFileto theimagefield (which is saved to the DB) and to the upload mappingarticle_image(defined invich_uploader.yaml). -
setImageFile():: Sets the uploaded file. If a new file is provided, it updates theupdatedAttimestamp so Doctrine detects a change and triggers the upload. -
getImageFile():: Returns the current file for use in forms or validation.This setup allows Symfony forms to handle file uploads cleanly while VichUploader automatically handles moving the file and updating the filename in the database.
-
-
What Is a Non-Persistent File Field?
A non-persistent file field is a property in a Symfony entity that:
-
Is not saved to the database.
-
Is used temporarily during the request, typically when handling file uploads.
-
Works together with
VichUploaderBundleto handle the upload process. -
Has a matching persistent field that stores the filename in the database.
-
-
Example from Your Code
private ?File $imageFile = null;-
This is the non-persistent field.
-
It is only used in memory by Symfony and VichUploader during form submission.
-
The actual filename is saved in a separate field, such as:
#[ORM\Column(length: 255)] private ?string $image = null;
-
-
Why Use It?
-
Doctrine does not store file objects—only strings like filenames.
-
Symfony uses this field to receive and process uploaded files.
-
The uploaded file is moved to the filesystem (e.g.
public/uploads/images), and the filename is saved in theimagefield.This approach keeps the database lightweight and delegates file storage to the filesystem.
-
-
-
In Article Entity Use
?stringin setImage() When Using VichUploaderBundleIf you’re using VichUploaderBundle, make sure your
setImage()method acceptsnull: This is required because VichUploader sets theimageproperty tonullwhen a file is removed. If you don’t allownull, the application will crash during deletion or cleanup.public function setImage(?string $image): staticIf your setter only accepts
string, like this:public function setImage(string $image): staticThen you get a runtime error:
Expected argument of type "string", "null" givenThis happens because Symfony (via VichUploader) tries to pass
null, but your setter expects a non-null string. -
Adjusting the Form File:
src/Form/ArticleForm.phpThis step adds the image upload field to the form using
VichUploaderBundle.// src/Form/ArticleForm.php use Vich\UploaderBundle\Form\Type\VichImageType; use Symfony\Component\Form\Extension\Core\Type\SubmitType; $builder ->add('title') ->add('content') ->add('imageFile', VichImageType::class, [ 'required' => false, 'label' => 'Image (JPG/PNG file)', 'download_uri' => false, ]) ->add('save', SubmitType::class);-
Explanation
-
titleandcontent:: Standard text fields for the article title and content. -
imageFilewithVichImageType::class:: Adds a file input for image upload, connected to theimageFilefield in the entity. -
'required' ⇒ false:: The image is optional. -
'label' ⇒ 'Image (JPG/PNG file)':: Custom label for the form field. -
'download_uri' ⇒ false:: Hides the download link for the already uploaded image. -
save:: Submit button for the form.This form configuration allows users to upload an image when creating or editing an article.
-
-
-
Edit Forms
// templates/article/_form.html.twig {{ form_start(form, {'attr': {'class': 'space-y-4 max-w-xl mx-auto p-4'}}) }} <div> {{ form_label(form.title, null, {'label_attr': {'class': 'block mb-1 text-sm font-medium'}}) }} {{ form_widget(form.title, {'attr': {'class': 'w-full p-2 border border-gray-400 rounded'}}) }} {{ form_errors(form.title) }} </div> <div> {{ form_label(form.content, null, {'label_attr': {'class': 'block mb-1 text-sm font-medium'}}) }} {{ form_widget(form.content, {'attr': {'class': 'w-full p-2 border border-gray-400 rounded h-40'}}) }} {{ form_errors(form.content) }} </div> {% if form.imageFile is defined %} <div> {{ form_label(form.imageFile, null, {'label_attr': {'class': 'block mb-1 text-sm font-medium'}}) }} {{ form_widget(form.imageFile, {'attr': {'class': 'w-full p-2 border border-gray-400 rounded'}}) }} {{ form_errors(form.imageFile) }} </div> {% endif %} <button class="bg-blue-600 text-white px-4 py-2 rounded">{{ button_label|default('Save') }}</button> {{ form_end(form) }},
templates/article/show.html.twig {% if article.image %} <img src="{{ asset('images/article/' ~ article.image) }}" alt="{{ article.title }}" class="mb-4 rounded"> {% endif %}and in templates/article/indx.html.twig
templates/article/indx.html.twig comment out the following line {# <td class="p-2 border">{{ article.image }}</td> #} and add instade the follwoing: {% if article.image %} <img src="{{ asset('images/article/' ~ article.image) }}" alt="{{ article.title }}" class="mb-4 rounded"> {% endif %} -
Create Directory and change Permissions
sudo mkdir -p public/images/article sudo chgrp www-data public/images/article sudo chmod -R 775 public/images/article -
Make commit
git status git add . git commit -m "Add image upload support for Article entity using VichUploaderBundle"
3.5.2. Resize Uploaded Images with VichUploaderBundle
This guide explains how to automatically resize uploaded images using VichUploaderBundle’s `pre_upload event and intervention/image-symfony.
-
Step 1: Install Intervention Image Symfony
Run this command in your project root:
composer require intervention/image-symfonyThis installs the Symfony integration of Intervention Image. It automatically registers the
ImageManagerservice. -
Step 2: Configure the Image Driver (Optional)
Create the file below if it does not exist:
# config/packages/intervention_image.yaml intervention_image: driver: Intervention\Image\Drivers\Gd\Driver options: autoOrientation: false strip: trueThis configures image orientation and metadata stripping.
-
Step 3: Create the Event Subscriber
Create this file manually:
# src/EventSubscriber/ImageResizeSubscriber.php <?php namespace App\EventSubscriber; use Intervention\Image\ImageManager; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Vich\UploaderBundle\Event\Event; use Vich\UploaderBundle\Event\Events; class ImageResizeSubscriber implements EventSubscriberInterface { private ImageManager $imageManager; public function __construct(ImageManager $imageManager) { $this->imageManager = $imageManager; } public static function getSubscribedEvents(): array { return [ Events::PRE_UPLOAD => 'onPreUpload', ]; } public function onPreUpload(Event $event): void { $object = $event->getObject(); // Adjust this condition for your actual entity class if (!method_exists($object, 'getImageFile')) { return; } $file = $object->getImageFile(); if ($file === null) { return; } $image = $this->imageManager->read($file->getPathname()); $image->resize(1000, 1000, function ($constraint) { $constraint->aspectRatio(); $constraint->upsize(); }); $image->save($file->getPathname(), 80); // Save at 80% quality } } -
Step 4: Register the Subscriber in Symfony
Open this file:
config/services.yamlMake sure this block exists:
# config/services.yaml App\EventSubscriber\ImageResizeSubscriber: tags: - { name: kernel.event_subscriber }Symfony will now automatically call your subscriber when
vich_uploader.pre_uploadis triggered. -
Step 5: Test the Upload
-
Upload an image using your form.
-
Before the image is written to disk, the resize logic in
ImageResizeSubscriberis executed. -
The final saved image will be resized (e.g. max width = 1000px).
⚠️ Make sure your browser or frontend expects
.webpif you change the format.
-
-
Recap: Files You Created or Modified
| File | Purpose |
|---|---|
|
Configure the image driver (optional) |
|
Resize the image before upload is saved |
|
Register the subscriber in the Symfony event system |
3.5.3. Optimize Configuration and Convert Images to WebP Format
To reduce file size and improve performance, uploaded images can be resized and converted to the efficient WebP format using the Intervention Image library.
-
Step 1: Install Required PHP Extension
Install
php-imagick, a PHP extension that enables high-quality image processing by leveraging the ImageMagick library. It provides better performance and broader format support than the default GD driver.sudo apt update sudo apt install php-imagick sudo systemctl restart apache2 -
Step 2: Configure the Intervention Image Driver (Optional)
You can configure Intervention to use the Imagick driver and enable advanced options:
# File: config/packages/intervention_image.yaml intervention_image: driver: Intervention\Image\Drivers\Imagick\Driver options: autoOrientation: true strip: true blendingColor: 'ffffff' decodeAnimation: true -
Step 3: Update the Image Resize Subscriber
This example resizes uploaded images and converts them to WebP format.
# File: src/EventSubscriber/ImageResizeSubscriber.php <?php namespace App\EventSubscriber; use Intervention\Image\ImageManager; # use Intervention\Image\Encoders\WebpEncoder; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Vich\UploaderBundle\Event\Event; use Vich\UploaderBundle\Event\Events; class ImageResizeSubscriber implements EventSubscriberInterface { private ImageManager $imageManager; public function __construct(ImageManager $imageManager) { $this->imageManager = $imageManager; } public static function getSubscribedEvents(): array { return [ Events::PRE_UPLOAD => 'onPreUpload', ]; } public function onPreUpload(Event $event): void { $object = $event->getObject(); if (!method_exists($object, 'getImageFile')) { return; } $file = $object->getImageFile(); if ($file === null) { return; } $image = $this->imageManager->read($file->getPathname()); $image->resize(1000, 1000, function ($constraint) { $constraint->aspectRatio(); $constraint->upsize(); }); $image->encode(new WebpEncoder(quality: 85))->save($file->getPathname(), 80); } }NoteThe use of WebpEncoderis required in Intervention Image v3+ for correct WebP conversion. -
Optional: Other Available Encoders
Intervention Image 3.x requires explicit encoder classes instead of file format strings.
The following encoders are available:
Format Class JPEG
JpegEncoderPNG
PngEncoderWEBP
WebpEncoderAVIF
AvifEncoderGIF
GifEncoderTIFF
TiffEncoderAll encoder classes are located under the namespace:
Intervention\Image\Encoders\-
Example: Encoding as JPEG
use Intervention\Image\Encoders\JpegEncoder; $image->encode(new JpegEncoder(quality: 85))->save('/path/to/image.jpg');
-
Reference
-
VichUploader Events: https://github.com/dustin10/VichUploaderBundle/blob/main/docs/events.md
-
Intervention Image Symfony: https://github.com/Intervention/image-symfony
3.5.4. Image Manipulation and Multi-Size Rendering
Using LiipImagineBundle with VichUploaderBundle in Symfony
-
Install LiipImagineBundle
composer require liip/imagine-bundle -
Configure LiipImagineBundle
File:
config/packages/liip_imagine.yaml# Documentation on how to configure the bundle can be found at: https://symfony.com/doc/current/bundles/LiipImagineBundle/basic-usage.html liip_imagine: # The image processing driver to use. # Options: "gd", "gmagick", or "imagick". # "gd" is widely available but limited in features compared to the others. driver: "gd" resolvers: default: web_path: # The root directory of the public web folder. # This is where generated (cached) images will be stored. web_root: "%kernel.project_dir%/public" # The subdirectory inside the public folder where cached images will be saved. # Final path will be: public/media/cache cache_prefix: "media/cache" filter_sets: # An empty filter used as a placeholder for cache warmup commands. # Does not perform any actual image transformation. cache: ~ # Filter for small thumbnails (e.g., avatars, icons) thumb_small: # JPEG compression quality (0–100). Higher means # better quality and larger file size. quality: 80 filters: thumbnail: # Resize the image to fit within 150x150 pixels while # maintaining aspect ratio. size: [150, 150] # 'inset' mode scales the image down to fit inside # the box without cropping. mode: inset # Filter for medium thumbnails (e.g., article previews or card images) thumb_medium: quality: 85 filters: thumbnail: # Resize to fit within 600x400 pixels. size: [600, 400] # Keeps the entire image visible within the defined size. mode: inset # Filter for large images (e.g., headers or full-width banners) thumb_large: quality: 80 filters: thumbnail: # Resize to exactly 1200x800 pixels. size: [1200, 800] # 'outbound' mode scales and crops the image to # fill the box completely. mode: outbound -
Create and Set Permissions for Symfony Media Cache Directory
mkdir -p public/media/cache sudo chgrp -R www-data public/media sudo chmod -R 775 public/media -
Step-by-Step: Updating the Twig Template
-
Before: Basic Static Image Path
# index.html.twig <img src="{{ asset('images/article/' ~ article.image) }}" alt="{{ article.title }}" class="w-12 h-auto rounded shadow">This example uses the
asset()function to load an image from thepublic/images/article/directory. It displays the full-sized image as uploaded without any resizing. -
After: Dynamic Resized Image with Liip Filter
#index.html.twig <img src="{{ asset('images/article/' ~ article.image)|imagine_filter('thumb_small') }}" alt="{{ article.title }}" class="w-12 h-auto rounded shadow">This updated version applies the
thumb_smallfilter defined inliip_imagine.yaml. LiipImagineBundle intercepts the image request, generates a resized version if necessary, and serves it from the cache. -
Highlight of the Change
Old Twig New Twig with Liip Filter asset('images/article/' ~ article.image)asset('images/article/' ~ article.image) + |imagine_filter('thumb_small') -
What This Does
-
Keeps your original image untouched.
-
Generates and caches a resized image on first access.
-
Reduces bandwidth and improves performance.
-
Allows you to manage multiple image sizes using filter sets.
-
-
In the same way as
thumb_small, you can also use the other predefined filters likethumb_mediumandthumb_largein different Twig templates depending on the context:-
thumb_medium— ideal for article previews or cards -
thumb_large— suitable for full-width banners or detail views (show.html.twig)
-
-
-
Clear or Warm Up Cache (optional)
Clear all image caches:
php bin/console liip:imagine:cache:removeWarm up all images for one filter:
find public/images/article -type f -iname '*.jpg' -o -iname '*.png' | while read img; do php bin/console liip:imagine:cache:resolve "${img#public/}" --filter=thumb_small donegor create shell "warmup_liip_images.sh" script in the root directory:
#!/bin/bash set -e # Pfad zum Bildordner unter /public/ IMAGE_DIR="public/images/article" # === Konfiguration: Filter === # Leerlassen für alle Filter automatisch: FILTERS="thumb_small thumb_medium thumb_large" # Oder: FILTERS="thumb_small thumb_large" # Wenn leer, hole alle Filter automatisch if [ -z "$FILTERS" ]; then FILTERS=$(php bin/console liip:imagine:filter:list --no-ansi | grep -v "^Filter sets:" | xargs) fi # Bilder rekursiv finden und alle gewünschten Filter darauf anwenden find "$IMAGE_DIR" -type f \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' \) | while read -r img; do RELATIVE_PATH="${img#public/}" for FILTER in $FILTERS; do echo "▶ Warming up: $RELATIVE_PATH [filter: $FILTER]" php bin/console liip:imagine:cache:resolve "$RELATIVE_PATH" --filter="$FILTER" done done echo "✅ Cache warmup done."Set Execute Permission (Make the script executable):
chmod +x warmup_liip_images.shExecute the Script
./warmup_liip_images.sh
✅ Ready to Go
+ You now have multiple responsive sizes for uploaded images via VichUploader, served and cached through LiipImagineBundle.
4. Displaying Resized Images in Twig with LiipImagineBundle
LiipImagineBundle allows you to generate and serve dynamically resized images using filters. This is especially useful for thumbnails, previews, or optimized images across your Symfony project.
This guide explains how to modify a standard <img> tag in Twig to use the LiipImagineBundle filter system.
4.1. Prerequisites
Make sure you’ve already:
-
Installed and configured
liip/imagine-bundle -
Defined at least one filter set in
config/packages/liip_imagine.yaml(e.g.,thumb_small)
Example filter set:
liip_imagine:
filter_sets:
thumb_small:
quality: 80
filters:
thumbnail:
size: [150, 150]
mode: inset
4.2. Step-by-Step: Updating the Twig Template
4.2.1. Before: Basic Static Image Path
{# <img src="{{ asset('images/article/' ~ article.image) }}" alt="{{ article.title }}" class="w-12 h-auto rounded shadow"> #}
This example uses the asset() function to load an image from the public/images/article/ directory. It displays the full-sized image as uploaded without any resizing.
4.2.2. After: Dynamic Resized Image with Liip Filter
<img src="{{ asset('images/article/' ~ article.image)|imagine_filter('thumb_small') }}" alt="{{ article.title }}" class="w-12 h-auto rounded shadow">
This updated version applies the thumb_small filter defined in liip_imagine.yaml. LiipImagineBundle intercepts the image request, generates a resized version if necessary, and serves it from the cache.
4.3. Highlight of the Change
| Old Twig | New Twig with Liip Filter |
|---|---|
|
|
4.4. What This Does
-
Keeps your original image untouched.
-
Generates and caches a resized image on first access.
-
Reduces bandwidth and improves performance.
-
Allows you to manage multiple image sizes using filter sets.
4.5. Next Steps
You can define additional filters for different contexts like:
-
thumb_mediumfor previews -
thumb_largefor full-width banners
Then use them like this:
<img src="{{ asset('images/article/' ~ article.image)|imagine_filter('thumb_large') }}">
4.6. Troubleshooting Tips
-
Ensure the original image exists in the specified path.
-
Make sure the cache directory (
public/media/cache/) is writable. -
If images are not showing, run:
php bin/console cache:clear && php bin/console liip:imagine:cache:resolvefor testing.