id-sign / image-bundle
Symfony bundle for automatic image optimization — resize, format negotiation (AVIF/WebP), srcset, blur placeholder, HTTP cache headers.
Requires
- php: >=8.2
- ext-imagick: *
- psr/log: ^2.0|^3.0
- symfony/config: ^7.4|^8.0
- symfony/console: ^7.4|^8.0
- symfony/dependency-injection: ^7.4|^8.0
- symfony/http-foundation: ^7.4|^8.0
- symfony/http-kernel: ^7.4|^8.0
- symfony/routing: ^7.4|^8.0
- symfony/service-contracts: ^3.0
- symfony/ux-twig-component: ^2.25|^3.0.0
- symfony/yaml: ^7.4|^8.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.94
- phpstan/phpstan: ^2.0
- phpstan/phpstan-symfony: ^2.0
- phpunit/phpunit: ^11.0 || ^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-main
- 0.4.0
- 0.3.0
- 0.2.4
- 0.2.3
- 0.2.2
- 0.2.1
- 0.2.0
- 0.1.13
- 0.1.12
- 0.1.11
- 0.1.10
- 0.1.9
- 0.1.8
- 0.1.7
- 0.1.6
- 0.1.5
- 0.1.4
- 0.1.3
- 0.1.2
- 0.1.1
- 0.1.0
- dev-fix/purge-simplify
- dev-docs/web-server-snippets
- dev-fix/purge-race-and-options
- dev-fix/purge-whole-directory
- dev-docs/upgrade-md
- dev-fix/height-and-modes
- dev-fix/purge-ownership
- dev-fix/watermark-geometry
- dev-fix/fit-validation
- dev-fix/config-env-paths
- dev-fix/purge-bundle-files-only
- dev-fix/signed-url-leading-zeros
- dev-fix/route-prefix-from-config
- dev-fix/config-validation
- dev-fix/device-sizes-config
- dev-fix/formats-config
- dev-feature/format-quality
This package is auto-updated.
Last update: 2026-10-30 19:34:50 UTC
README
Lightweight yet powerful image optimization bundle for Symfony. Provides a <twig:Image> component that generates
<picture> tags with automatic resize, client-side format negotiation (AVIF/WebP), responsive srcset, blur
placeholders, watermark profiles, and HMAC-signed URLs.
Built on ext-imagick with no additional dependencies — zero CLI tools, no third-party image libraries. Designed for
performance: web server serves cached images directly via try_files with no PHP overhead. Extensible through
interfaces for custom image sources and cache storage. Ready for FrankenPHP worker mode.
Requirements
- PHP 8.2+
- Symfony ^7.4 | ^8.0
- ext-imagick, with ImageMagick built with the
lcmsdelegate (convert -list configure | grep DELEGATES) — it performs the ICC colour conversion; without it profiled sources are logged and served unconverted, see Colour profiles and metadata
Installation
composer require id-sign/image-bundle
If Symfony Flex doesn't register the bundle automatically, add it to config/bundles.php:
// config/bundles.php return [ // ... IdSign\ImageBundle\IdSignImageBundle::class => ['all' => true], ];
Import the bundle routes:
# config/routes/id_sign_image.yaml id_sign_image: resource: '@IdSignImageBundle/config/routes.yaml'
Configuration
# config/packages/id_sign_image.yaml id_sign_image: # Breakpoints for responsive srcset generation # A later config file (e.g. a when@prod block) replaces the list instead of extending it. device_sizes: [ 320, 480, 640, 750, 828, 1080, 1200, 1920, 2048, 3840 ] # Default output quality (1-100) default_quality: 80 # Output quality per format (1-100) for the formats listed; the others use default_quality. # Keys: avif, webp, jpeg, png ("jpeg", never "jpg"). Default: none. format_quality: avif: 55 webp: 75 # Named image sources (name => absolute base path), referenced as "@name/path" in src. # Paths without a prefix use "default"; when omitted, default is %kernel.project_dir%/data. sources: default: '%kernel.project_dir%/data' reference: '%kernel.project_dir%/assets/reference' # Output formats in priority order (used for <source> elements in <picture>): avif, webp, jpeg, png. # A later config file (e.g. a when@prod block) replaces the list instead of extending it. formats: [ 'avif', 'webp' ] # Cache settings cache: ttl: 2592000 # 30 days in seconds (controller mode only); 0 = re-encode on every request (debugging) path: ~ # absolute; default: %kernel.project_dir%/public + route_prefix, where try_files finds # the files; controller mode with access control: move outside public/ # Blur placeholder blur: enabled: false # Global default, can be overridden per component size: 10 # Placeholder width in pixels, 1-64 (inlined as a data URI into every page) quality: 30 # JPEG quality of the placeholder # Default watermark profile applied to all images (null = no watermark); must be one of the profiles below default_watermark: ~ # Named watermark profiles. Names may contain letters, digits, "_" and "-" and are kept verbatim — they are # what the watermark attribute and default_watermark refer to, and they appear in image URLs. watermarks: copyright: path: '%kernel.project_dir%/data/watermark.png' # Absolute path to watermark image (PNG with transparency) position: 'bottom-right' # top-left, top-center, top-right, center-left, center, center-right, bottom-left, bottom-center, bottom-right opacity: 50 # 0-100 size: 20 # Watermark width as percentage of output image width, 1-100 margin: 10 # Margin from edge in pixels, at least 0 # Define additional profiles as needed: # client_logo: # path: '%kernel.project_dir%/data/client-logo.png' # position: 'top-right' # opacity: 30 # size: 15 # margin: 20 # Auto-calculate height from source aspect ratio when only width is provided auto_dimensions: false # Use lossless encoding by default (WebP/AVIF only — ignored for JPEG/PNG) lossless: false # Safety limits — protect against memory exhaustion and decompression bombs max_width: 4096 # Reject component width above this (pixels) max_source_bytes: 20971520 # Reject source files above this (bytes). 0 disables. max_source_pixels: 64000000 # Reject source images above this many pixels (width × height). 0 disables. # Permissions for created cache files and directories file_permissions: 0o660 # octal with "0o" — YAML reads 0660 as a string; null = use umask default directory_permissions: 0o770 # the owner needs rwx # 'public' = web server serves cached files directly (try_files) # 'controller' = every request goes through PHP (enables TTL-based invalidation) serve_mode: 'public' # URL prefix of every image URL: starts with "/", no trailing "/". The route and the default cache.path follow it — # import the bundle routes without a "prefix:". route_prefix: '/_image'
Usage
Basic
<twig:Image src="uploads/photo.jpg" width="800" height="600" alt="A photo" />
Output:
<picture> <source type="image/avif" srcset="/_image/.../320_auto_none_80.avif 320w, /_image/.../480_auto_none_80.avif 480w, /_image/.../640_auto_none_80.avif 640w, /_image/.../750_auto_none_80.avif 750w, /_image/.../800_auto_none_80.avif 800w" sizes="100vw"> <source type="image/webp" srcset="/_image/.../320_auto_none_80.webp 320w, /_image/.../480_auto_none_80.webp 480w, /_image/.../640_auto_none_80.webp 640w, /_image/.../750_auto_none_80.webp 750w, /_image/.../800_auto_none_80.webp 800w" sizes="100vw"> <img src="/_image/.../800_auto_none_80.jpeg" width="800" height="600" alt="A photo" decoding="async"/> </picture>
height without fit is a layout hint: it lands on <img> for CLS prevention while the server resizes by width
only (auto in the URL) and keeps the source aspect ratio — see height. One srcset candidate per default
device_sizes breakpoint below width, plus width itself — see Picking sizes.
With blur placeholder and fit mode
<twig:Image src="uploads/hero.jpg" width="1200" height="600" fit="cover" blur alt="Hero image" class="w-full rounded-lg" loading="lazy" sizes="100vw" />
With watermark
{# Apply a specific watermark profile #} <twig:Image src="uploads/photo.jpg" width="800" height="600" watermark="copyright" alt="Photo" /> {# Apply a different profile #} <twig:Image src="uploads/photo.jpg" width="800" height="600" watermark="client_logo" alt="Photo" /> {# Disable watermark even if default_watermark is set globally #} <twig:Image src="uploads/photo.jpg" width="800" height="600" :watermark="false" alt="Photo" /> {# Uses default_watermark from config (null = no watermark by default) #} <twig:Image src="uploads/photo.jpg" width="800" height="600" alt="Photo" />
Custom quality
{# One quality for every format #} <twig:Image src="uploads/avatar.jpg" width="64" height="64" quality="90" alt="Avatar" class="rounded-full" /> {# Per format — formats not named keep format_quality / default_quality #} <twig:Image src="uploads/hero.jpg" width="1920" :quality="{avif: 70, webp: 90}" alt="Hero" />
SVG passthrough
SVG files are served without any image processing — no resize, no format conversion, no srcset. The file is copied to cache on first request and served via try_files (public mode) or controller. No HMAC signature is needed.
<twig:Image src="icons/logo.svg" width="120" height="40" alt="Logo" />
Output:
<img src="/_image/icons/logo.svg" width="120" height="40" alt="Logo" decoding="async"/>
Colour profiles and metadata
Every variant is written in sRGB without metadata. A source with an embedded ICC profile — Display P3 (every iPhone
photo), Adobe RGB, a monitor profile in a screenshot, a CMYK press profile — is converted to sRGB through that profile
before it is stripped, so the colours stay as intended; a source without a profile is taken as sRGB — except a camera
JPEG shot in Adobe RGB mode, which carries no profile but says so in EXIF (InteroperabilityIndex R03) and is
converted as Adobe RGB. A watermark image with a profile is converted the same way before it is composited. EXIF
orientation is applied before the EXIF block is dropped. The conversion runs on the resized output (about 40 ms for 800 × 600) and only when a variant is
generated; a source tagged with one of the sRGB profile variants (camera and Photoshop JPEGs) is recognised in about
2 ms and not converted. A profile that cannot be converted (truncated, or not matching the image) is logged as a
warning and stripped without conversion. The sRGB target is the ICC's sRGB2014.icc, bundled in resources/; the
bundle refuses to build the container when that file is missing. The conversion runs in LittleCMS through ImageMagick,
on the profile embedded in the source file — keep lcms2 as current as ImageMagick itself. An ImageMagick built
without the lcms delegate cannot convert at all: every profiled source is then logged as a warning on cache miss and
served with the profile stripped, i.e. with the washed-out colours of 0.3.
Not handled, and taken as sRGB like browsers do: PNG files that declare their primaries only through cHRM/gAMA
chunks, and AVIF/HEIC files whose colour space is given only by an nclx box without an ICC profile (avifenc,
Pixelmator, Affinity; iPhone HEIC embeds a profile and is converted).
Component attributes
| Attribute | Type | Required | Description |
|---|---|---|---|
src |
string | yes | Source path, optionally @source/-prefixed (see Image sources) |
width |
int | yes | Intrinsic image width in px (not display size) — required, see below |
height |
int | no | Display height (auto-calculated if auto_dimensions enabled) |
fit |
string | no | Resize mode: cover, contain, or scale-down |
blur |
bool | no | Show blur placeholder (overrides global setting) |
quality |
int|map | no | Output quality 1-100, or a {format: quality} map (see quality) |
autoDimensions |
bool | no | Auto-calculate height from aspect ratio (overrides global auto_dimensions) |
watermark |
string|false | no | Watermark profile name, false to disable, omit for global default |
lossless |
bool | no | Use lossless encoding (WebP/AVIF only, overrides global config) |
All other attributes (alt, class, id, loading, data-*, aria-*, etc.) are passed through to the <img> tag.
Props are HTML attributes with the usual ux-twig-component rules: a plain attr="…" passes a string (width="800" is
coerced to int), :attr="…" evaluates a Twig expression — needed for false, variables and anything that is not a
literal string (:width="image.width", :watermark="false") — and a bare attribute means true (blur, lossless).
The same props can be passed as a named map through the function syntax, handy in loops and macros where they come
from variables:
{{ component('Image', { src: image.path, width: 800, fit: 'cover', alt: image.title, class: 'card__img' }) }}
src
Path to the source image relative to the default source directory (data/ unless configured). Prefix it with
@name/ to read from another configured source, e.g. @reference/logo.png — see Image sources.
@default/photo.jpg means the same as photo.jpg, and so does /photo.jpg — a leading slash is dropped, so every
spelling shares one URL and one cache directory. The file must exist on the filesystem. For SVG files, no processing
is applied — the image is served directly.
width
The intrinsic width of the generated image file in pixels — not the rendered CSS size. Used as the width HTML
attribute (CLS prevention), as the main size in srcset, and as the upper bound for responsive breakpoints
(SrcsetGenerator only emits device_sizes breakpoints ≤ width).
width is required and must be > 0 — omitting it (or passing 0) throws InvalidArgumentException at render time,
applies to SVG as well. Pick a value that matches the largest pixel size the image will ever render at.
Picking the right value:
- Full-bleed image (
class="w-full",sizes="100vw"): use the largest realistic render size, typically the largest relevantdevice_sizesentry (e.g.1920or2048). The browser picks from all breakpoints ≤ width. - Fixed-size image in a column: set
widthto the max CSS width the image can reach, optionally 2× for retina. Pair with a matchingsizes, e.g.sizes="(min-width: 1024px) 500px, 100vw". widthsmaller than smallestdevice_sizesentry (default320) produces a single-candidate srcset — on wide viewports the image gets stretched and looks blurry. Raisewidth, or narrowsizesto reflect the real layout.- SVG:
widthonly affects the<img width="…">HTML attribute — no server-side processing happens. Pick the layout size you want reserved for CLS.
Mismatch between width and sizes wastes bandwidth or causes blurry rendering — see Picking sizes
below.
Picking sizes
The sizes attribute tells the browser how wide the image will render at each viewport size. The browser multiplies
that against DPR and picks the smallest srcset candidate ≥ the target. Without a correct sizes, responsive srcset is
wasted bandwidth: the bundle default sizes="100vw" makes every browser download the largest candidate regardless of
actual render size.
Set sizes to match the real CSS layout. Common patterns:
- Full-bleed (edge-to-edge):
sizes="100vw"(bundle default — only correct if the image truly spans the viewport). - Fixed max-width container with full-bleed mobile:
sizes="(min-width: 1280px) 1280px, 100vw". - Grid column, breakpoint-dependent:
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw". - Fixed pixel size:
sizes="400px".
Pass sizes as any other attribute — it propagates to every <source> and <img>:
<twig:Image src="uploads/hero.jpg" width="1920" sizes="(min-width: 1280px) 1280px, 100vw" alt="Hero" class="w-full h-auto" />
height
The display height in pixels. When omitted and auto dimensions is enabled (globally via auto_dimensions config or
per-component via autoDimensions prop), the height is automatically calculated from the source image's aspect ratio.
When both width and height are provided with a fit mode, the server produces an image with exactly these
dimensions. Without fit, height is only the HTML attribute: the server resizes by width and keeps the source
aspect ratio. A height below 1 throws an InvalidArgumentException.
autoDimensions
Overrides the global auto_dimensions config for this specific component instance. When true, height is
auto-calculated from the source aspect ratio (if height is not provided). When false, auto-calculation is disabled
even if the global config is enabled. When omitted (null), the global config value is used. When the source image does
not exist, no height is rendered and the page still renders — the image itself returns 404.
fit
Controls how the image is resized when both width and height are specified:
cover— fills the target dimensions, cropping any overflow. Crop is always centered. For custom crop positioning, omitfitand use CSSobject-fit+object-positioninstead — this gives full control over the visible area in the browser.contain— fits within the target dimensions, preserving aspect ratio (may leave empty space)scale-down— same ascontain, but never upscales images smaller than the target. When the source is smaller, the renderedwidth/heightattributes,srcsetand URL reflect the actual source size.
When omitted, the server resizes by width only and keeps the source aspect ratio; height then only sets the HTML
attribute (see height). Any other value — fill, Cover, none — throws an InvalidArgumentException
when the component is rendered, image_url() is called or ImageUrlGenerator builds the URL.
blur
Enables an inline blur placeholder. A tiny 10px-wide JPEG thumbnail is base64-encoded and rendered as a CSS
background-image with filter: blur(20px). Once the full image loads, the placeholder is removed via onload. The
placeholder is generated on-demand and cached on disk. When the source image does not exist, no placeholder is rendered
and the image itself returns 404.
Can be set globally via blur.enabled in bundle configuration. The component attribute overrides the global setting.
quality
Output compression quality (1-100). Lower values produce smaller files with more compression artifacts. Typical values: 60-80 for photos, 80-90 for detailed images.
The formats do not compress alike — AVIF reaches the look of a WebP or JPEG at a clearly lower value — so the quality
can be set per format. Each <source> and the <img> fallback get the first of:
- the
qualityattribute — an int applies to every format, a map such as:quality="{avif: 70, webp: 90}"only to the formats it names; format_qualityfrom the configuration;default_quality.
Map keys are output formats: avif, webp, jpeg, png (for PNG the value sets the zlib level and filter, not a
visual quality). A value outside 1-100 or an unknown key throws an InvalidArgumentException; in the configuration it
fails the container build. Tune the values by eye on your own images — the right AVIF value depends on the encoder
build.
The quality is part of the URL and of the cache path, so changing format_quality or default_quality changes the URLs
of the affected formats: the new variants are generated on first request, the old ones stay on disk until
image:purge.
watermark
Selects a named watermark profile to apply. Profiles are defined in watermarks in the bundle configuration, each with
its own image, position, opacity, size, and margin.
watermark="copyright"— applies thecopyrightprofile:watermark="false"— disables watermark even ifdefault_watermarkis set globally- Omitted — uses the
default_watermarkprofile from config (null = no watermark)
Each watermark profile produces a separate cached file with a distinct URL, so the same image can exist with different watermarks or without one.
lossless
Switches the encoder into its lossless mode for formats that support it. Useful for screenshots, diagrams, UI assets,
pixel art, and anything where quality=100 still visibly softens edges or introduces banding.
{# Single lossless asset #} <twig:Image src="docs/screenshot.png" :width="1200" lossless /> {# With watermark #} <twig:Image src="icons/diagram.png" :width="600" lossless watermark="copyright" /> {# Disable even if set globally #} <twig:Image src="photos/hero.jpg" :width="1920" :lossless="false" />
Format matrix:
| Output format | lossless=true behaviour |
|---|---|
webp |
True lossless WebP (separate codec path, not quality=100) |
avif |
True lossless AVIF via libheif where the AV1 encoder plugin is present (see below); otherwise falls back to quality=100 ("visually lossless") rather than silently dropping to the lossy default |
jpeg / jpg |
Silently ignored — JPEG has no lossless mode. quality applies normally |
png |
Silently no-op — PNG is always lossless |
Lossless encoding is 5-50× slower and produces 2-10× larger files than lossy at quality=80. For photographic
content the visual difference is imperceptible; use lossless only where bit-exact pixels actually matter.
AVIF runtime requirement: libheif on Debian/Ubuntu 24.04+ ships without an AV1 encoder by default. Install
libheif-plugin-aomenc on the host where PHP runs. Without it, AVIF lossless (and even lossy) encoding fails with
no encode delegate for image format AVIF.
# Debian / Ubuntu 24.04+
apt-get install libheif-plugin-aomenc
lossless can be set globally via the lossless config key and overridden per-component via the prop. The global
default is false.
Serve modes
Public mode (default)
Cached images are stored at a path matching the URL. The web server serves them directly via try_files, PHP is only
called on the first request per variant.
Nginx example (the fallback is the /index.php front controller of the standard Symfony vhost):
location /_image/ { try_files $uri /index.php$is_args$args; }
A regex location for image extensions — the usual block that sets long expires for assets — takes precedence over
this prefix location: give it the same try_files $uri /index.php$is_args$args;, or every variant that is not cached
yet answers 404 and is never generated.
Caddy: the standard Symfony Caddyfile (php_fastcgi plus file_server) needs nothing extra — php_fastcgi tries the
file first and file_server serves it. A dedicated block needs its own file_server: php_fastcgi does not serve
static files, and handle blocks exclude each other, so without it every cached image answers an empty 200.
handle /_image/* {
try_files {path}
php_fastcgi unix//run/php-fpm.sock
file_server
}
Apache: the standard Symfony vhost (FallbackResource /index.php or the symfony/apache-pack rewrite rules) needs
nothing extra.
FrankenPHP needs no extra configuration: php_server serves existing files from the web root before handing the
request to PHP (its built-in try_files {path} {path}/index.php index.php), and @ in the URL (%40) works.
Controller mode
Every image request goes through PHP. Enables TTL-based cache invalidation -- expired variants are regenerated on the next request. Use this when you need access control or TTL-based refresh.
For access control, move cache.path out of the document root. With the default (%kernel.project_dir%/public plus
route_prefix) a standard Symfony vhost (try_files $uri /index.php$is_args$args) serves an already cached variant
as a static file: the first authorized visitor fills the cache, everybody after them gets the file without PHP, and the
firewall never sees the request.
In both modes a cached variant is served without consulting the source, so deleting a source file does not remove its
variants: call deleteBySource() or image:purge <src> --force when a source is deleted (see
Cache management). In controller mode the variants then expire with cache.ttl at the latest.
id_sign_image: serve_mode: 'controller' cache: ttl: 86400 # Re-generate after 24 hours path: '%kernel.project_dir%/var/image-cache' # outside public/, see above
Custom route prefix
Set route_prefix — the route and the default cache directory follow it:
id_sign_image: route_prefix: '/media/images'
The bundle route takes its path from route_prefix ('%id_sign_image.route_prefix%/{path}'), so import
@IdSignImageBundle/config/routes.yaml without a prefix: — a prefix on the import would be applied on top. The
default cache.path follows too (%kernel.project_dir%/public/media/images), so in public mode the web server finds
the cached files under the URL path. If you set cache.path yourself in public mode, it has to be the document root
plus route_prefix. The web server snippets, access_control rules and nginx locations in this README use the default
/_image — replace it with your prefix.
Pick a prefix whose directory belongs to the bundle alone. The cache directory — by default the document root plus
route_prefix — is emptied by image:purge (see Cache management), files of your own in it
included. route_prefix: /images over a public/images/ that holds your static images loses them on the first purge.
A configuration in which the cache directory is a source directory or contains one
(sources.default: '%kernel.project_dir%/public/images' with route_prefix: /images) fails the container build.
Restricting access to authenticated users
Use controller mode with Symfony security to require authentication for image access:
# config/packages/id_sign_image.yaml id_sign_image: serve_mode: 'controller' cache: path: '%kernel.project_dir%/var/image-cache' # must not be under public/
# config/packages/security.yaml security: access_control: - { path: ^/_image, roles: ROLE_USER } # your route_prefix
In public mode, the web server serves cached files directly and bypasses PHP — Symfony's security firewall is not
involved. The same happens in controller mode while cache.path stays under the document root: the web server's
try_files finds the cached variant and serves it without PHP. Controller mode with the cache outside public/ is
what makes the firewall see every request.
Handing cached files to the web server (X-Accel-Redirect / X-Sendfile)
In controller mode PHP streams every cached file through Symfony's BinaryFileResponse. With X-Accel-Redirect
(nginx, FrankenPHP) or X-Sendfile (Apache mod_xsendfile) PHP answers with a header only and the web server sends
the file itself: sendfile, range requests and slow clients stop being PHP's business, while the firewall and the
controller still run for every request, so access control is unchanged. With nginx + PHP-FPM the worker is already
released once nginx has buffered the response, so the gain there is the CPU and memory of copying the file through PHP
and FastCGI; with FrankenPHP, where PHP writes straight to the client connection, it also frees the PHP thread from slow
clients. The bundle needs no configuration for this — it is the standard BinaryFileResponse support.
-
Keep
cache.pathoutside the document root (required for access control in controller mode, see above) and let Symfony trust theX-Sendfile-Typerequest header:# config/packages/framework.yaml framework: trust_x_sendfile_type_header: true # Symfony 7.2+: or SYMFONY_TRUST_X_SENDFILE_TYPE_HEADER=1
-
Tell the web server to pass the type and the path mapping to PHP and to serve the cache directory from an internal location. nginx:
location ~ ^/index\.php(/|$) { # ... fastcgi_pass etc. fastcgi_param HTTP_X_SENDFILE_TYPE X-Accel-Redirect; fastcgi_param HTTP_X_ACCEL_MAPPING /var/www/app/var/image-cache/=/_image_internal/; } location /_image_internal/ { internal; alias /var/www/app/var/image-cache/; add_header X-Content-Type-Options "nosniff" always; add_header Content-Security-Policy $image_svg_csp always; # "sandbox" for .svg, empty = not sent otherwise }
with, at
httplevel,map $uri $image_svg_csp { ~*\.svg$ "sandbox"; default ""; }. nginx keepsContent-TypeandCache-Controlfrom the PHP response, so theimmutablecaching headers survive, but it drops the rest — including the SVG security headers the controller sets (see Webserver hardening for SVG), hence the twoadd_headerlines. FrankenPHP and Apache below keep every header of the PHP response and need nothing extra.FrankenPHP (after its X-Sendfile documentation, for a cache in
var/image-cacheof an application in/app, the working directory of the FrankenPHP image):root public/ request_header X-Sendfile-Type x-accel-redirect request_header X-Accel-Mapping /app/var/image-cache/=/ intercept { @accel header X-Accel-Redirect * handle_response @accel { root var/image-cache/ rewrite * {resp.header.X-Accel-Redirect} method * GET header -X-Accel-Redirect file_server } } php_server
The mapping has to be absolute: the relative
../var/image-cacheof the FrankenPHP documentation never matches, becauseBinaryFileResponsecompares it with the file'srealpath()— PHP then streams the file itself, without an error.rootis relative to FrankenPHP's working directory (or absolute).Apache with
mod_xsendfile(libapache2-mod-xsendfileon Debian/Ubuntu) andmod_headers:XSendFile On XSendFilePath /var/www/app/var/image-cache RequestHeader set X-Sendfile-Type X-Sendfile
In every mapping the path is the absolute, symlink-resolved
cache.path—BinaryFileResponsematches it againstrealpath(), so with a deployment symlink (current -> releases/42) give the resolved release path. A mapping that does not match is silent: PHP streams the file. An offloaded response carries anETagfrom the web server, whichBinaryFileResponsenever sends — a quickcurl -Ishows whether the hand-over works.
URL security
All raster image URLs are signed with HMAC-SHA256 derived from kernel.secret. The signature is part of the URL
path, so any modification of parameters (width, height, quality, etc.) results in a 403 Forbidden response. This
prevents cache exhaustion attacks where an attacker generates arbitrary image variants.
The output format is not part of the signature (one signature covers every <source> of a width), so the controller
generates only the formats the application links to: the configured formats plus the source's fallback format. Any
other extension answers 404. The fallback format follows the source extension and is also the format of the <img>
fallback of <twig:Image>:
| Source extension | Fallback format |
|---|---|
jpg, jpeg |
jpeg |
png |
png |
webp |
webp |
avif |
avif |
gif, tiff, tif, heic, heif, bmp, other |
jpeg |
When the formats carry different qualities (format_quality or a quality map), each signed quality can still be
requested in every offered format — the …_55.avif URL also yields …_55.webp and …_55.jpeg. A width then has at
most one variant per offered format and distinct quality (9 instead of 3 with avif, webp and a JPEG fallback),
which bounds what an attacker can generate from the URLs on your pages. This is an accepted trade-off that keeps a
single, format-free signature scheme for every URL.
SVG URLs are not signed — SVG has no parameter variations, so HMAC would add no protection against cache exhaustion. See Security model below for the implications.
Security model
The bundle's threat model treats every configured source directory (sources, default %kernel.project_dir%/data) as
a publicly accessible asset tree. Any file under any of them can be fetched by anyone who knows or guesses the path:
- Raster images — attacker needs a valid HMAC signature (generated by your application) to access any specific
(src, width, height, …)combination. They cannot forge arbitrary sizes or qualities. But the source file itself is public as soon as you render it anywhere with<twig:Image>orimage_url(). - SVG files — no signature is required. Any
.svgfile under any source directory is readable by anyone who knows or guesses the path (/_image/@reference/anything.svg).
What this means for your project
Do not store access-controlled or non-public files under any source directory. Adding a directory to sources
publishes it. The bundle does not enforce
authorization. If you need:
- Authorization per user/role: use
serve_mode: controllerwithcache.pathoutside the document root and add a Symfony firewall /#[IsGranted]in front of the bundle route. In public mode — and in controller mode with the cache underpublic/— the web server serves cached files directly and bypasses Symfony security. - Authorization per image: enforce it before the bundle route as well — a firewall rule, a request listener or a
controller of your own that checks the
srcand forwards. A customImageSourceInterfacecannot do it: a cached variant is served without consulting the source (see Serve modes), so anexists()that returnsfalsefor some users only takes effect while the variant is not cached yet. - Private storage with controlled access: implement a custom
ImageSourceInterfacepointing at a non-public location, and useserve_mode: controllerwith access control in front of the route.
Path traversal and symlinks
Input paths (src values) are checked for .., ., empty and backslash-containing segments (Windows takes \ as a
separator too) and rejected at the source and cache layer (404 from the controller). Symlinks are followed transparently — the bundle assumes any symlink inside the source directory was
placed there intentionally by the administrator. If you don't want symlinks followed, audit your source tree (e.g.
find data/ -type l) and remove unexpected ones before relying on the boundary.
Decompression bombs
max_source_bytes (default 20 MiB) rejects oversized source files, and max_source_pixels (default 64 million
pixels, 64 MP) rejects images whose header announces more pixels than that. The byte limit alone is not enough: a
highly compressed 100 MP JPEG is about 15 MB and a flat 30 000 × 30 000 PNG a few hundred kB, yet decoding either
needs over a gigabyte — above ImageMagick's memory policy it falls back to a disk-backed pixel cache that takes
seconds to minutes per variant. Both checks run before Imagick decodes anything (the pixel check reads only the
header, every frame of a GIF, APNG, animated WebP or multi-page TIFF counted — only the first frame is processed) and
the image URL answers 413; a page that renders such an image gets no height and no blur placeholder, like for a
missing source. These limits are a safety net, not upload validation — see
Validating uploaded images. Raise max_source_pixels for medium-format photography (100 MP and more) or set it to 0
to disable the check. Also configure ImageMagick's policy.xml to restrict memory/width/height limits and disable
dangerous coders (MVG, MSL, PDF, EPHEMERAL, URL). The bundle cannot substitute for a properly configured
ImageMagick policy.
SVG and XSS
SVG files can contain <script> and event handlers that execute in your origin when the SVG is loaded as a top-level
document. The bundle serves SVGs as-is — it does not strip scripts — but the controller sends them with
Content-Security-Policy: sandbox and X-Content-Type-Options: nosniff, which block script execution. In public mode
cache hits never reach the controller, so the web server has to add the same headers (see
Webserver hardening for SVG below). If your source directory can receive
user-uploaded SVGs, either sanitize before storage or make sure every served response carries those headers.
Webserver hardening for SVG
The controller sends SVG with Content-Type: image/svg+xml, Content-Security-Policy: sandbox and
X-Content-Type-Options: nosniff. In controller mode that is every response — unless the file is handed over with
X-Accel-Redirect, which drops those headers, see
above. In public mode PHP answers only the
first request for an SVG; every cache hit is served by the web server, which has to add the same two headers itself.
Match the extension case-insensitively: the bundle serves logo.SVG as SVG too.
Nginx
location /_image/ { try_files $uri /index.php$is_args$args; } location ~* ^/_image/.+\.svg$ { add_header Content-Security-Policy "sandbox" always; add_header X-Content-Type-Options "nosniff" always; try_files $uri /index.php$is_args$args; }
A regex location takes precedence over the prefix one, so the SVG block needs its own try_files with the
/index.php fallback of the standard Symfony vhost — with =404 an SVG that is not cached yet would never reach PHP
and could never be cached. The add_header lines do not apply to the fallback response; that one comes from the
controller with its own headers.
Apache
Needs mod_headers (a2enmod headers on Debian/Ubuntu).
<LocationMatch "(?i)^/_image/.+\.svg$"> Header always set Content-Security-Policy "sandbox" Header always set X-Content-Type-Options "nosniff" </LocationMatch>
Caddy
@image_svg path_regexp (?i)^/_image/.+\.svg$
header @image_svg Content-Security-Policy "sandbox"
header @image_svg X-Content-Type-Options "nosniff"
Content-Security-Policy: sandbox (with no directive value) applies the strictest sandbox: blocks scripts, forms,
popups, plugins, and same-origin access. The SVG still renders as an image. This is cheap (single header in response)
and effective against <script> and on* handlers.
If you cannot configure the web server in public mode and SVG hardening is a requirement, sanitize SVGs before they
land in the source directory — e.g. using enshrined/svg-sanitize on
upload — or use controller mode.
Validating uploaded images
The bundle does not validate uploaded files — that is the host project's job, best done with Symfony's built-in
Assert\Image constraint, which already covers size,
dimensions, aspect ratio, MIME type, and corruption detection:
use Symfony\Component\Validator\Constraints as Assert; class Product { #[Assert\Image( maxSize: '5M', maxWidth: 4000, maxHeight: 4000, mimeTypes: ['image/jpeg', 'image/png', 'image/webp'], detectCorrupted: true, )] public ?File $image = null; }
The bundle's max_source_bytes, max_source_pixels and max_width are runtime safety nets, not replacements for
proper upload validation: a source above one of the first two answers 413 and nothing is cached — the wrong moment to
learn that an upload was too large.
Image sources
Source images are read from named sources configured under sources. A path without a prefix uses the default
source (%kernel.project_dir%/data unless configured); @name/path reads from the source name:
# config/packages/id_sign_image.yaml id_sign_image: sources: default: '%kernel.project_dir%/public/uploads' reference: '%kernel.project_dir%/assets/reference'
<twig:Image src="uploads/photo.jpg" width="800" alt="From the default source" /> <twig:Image src="@reference/screenshot.png" width="1200" alt="From the reference source" /> {{ image_url('@reference/screenshot.png', 1200, format='webp') }}
The prefix stays part of the path everywhere: in URLs (/_image/%40reference/screenshot.png/…), in the HMAC signature
and in the cache directory (public/_image/@reference/screenshot.png/), so variants of @reference/logo.png and
logo.png never collide, and image:purge '@reference/screenshot.png' deletes exactly that source's variants.
@default/photo.jpg is normalized to photo.jpg on input, so both spellings share one URL and one cache directory.
Source names start with a letter, may contain letters, digits, _ and -, and are case-sensitive. Source paths must
be absolute (a %parameter% counts). A path with an unknown source name behaves like a missing file: the page renders
without height and blur, the image URL returns 404. Every configured source is a public asset tree — see
Security model.
Custom source implementations
Named sources are services implementing ImageSourceInterface; configured paths become LocalFilesystemSource
instances. To plug in a custom source (S3, Flysystem, …), tag your service with id_sign_image.source and a name:
use IdSign\ImageBundle\Source\ImageSourceInterface; use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag; #[AutoconfigureTag('id_sign_image.source', ['name' => 'reference'])] final class S3ReferenceSource implements ImageSourceInterface { // ... }
To use a custom implementation as the default source, re-point the id_sign_image.source.default alias:
# config/services.yaml services: id_sign_image.source.default: '@App\Image\S3Source'
Two pre-0.4 ways of configuring the source keep working: overriding the $basePath argument of
IdSign\ImageBundle\Source\LocalFilesystemSource still changes the default directory (and takes precedence over
sources.default), and replacing the ImageSourceInterface alias with your own service still works but bypasses
named sources — your implementation then receives the raw @name/… string.
Cache management
By default, all commands run in dry-run mode (showing what would be deleted). Add --force to actually delete files.
The cache directory (cache.path, by default the document root plus route_prefix) belongs to the bundle:
image:purge and CacheStorageInterface::deleteBySource() delete everything below it, or below the given source,
whoever wrote it. Do not put files of your own there. A request that encodes a variant at the very moment a purge
empties its directory can fail once; the next request generates the variant again.
Purge all cached images
# Preview php bin/console image:purge # Delete php bin/console image:purge --force
Purge a specific source image
php bin/console image:purge uploads/photo.jpg --force # Variants of an image from a named source php bin/console image:purge @reference/logo.png --force # Everything cached below a directory prefix or a whole source php bin/console image:purge uploads/2024 --force php bin/console image:purge @reference --force
Purge by age
# Files not modified in 30 days php bin/console image:purge --modified-before=30 --force # Files not accessed in 14 days php bin/console image:purge --accessed-before=14 --force # Combine both php bin/console image:purge --modified-before=30 --accessed-before=14 --force
Both options take a whole number of days.
Programmatic cache invalidation
Inject CacheStorageInterface to invalidate cache from your application code:
use IdSign\ImageBundle\Cache\CacheStorageInterface; class ImageUploadHandler { public function __construct( private readonly CacheStorageInterface $cacheStorage, ) {} public function onImageUpdated(string $src): void { $this->cacheStorage->deleteBySource($src); // e.g. 'uploads/photo.jpg' } }
This deletes all cached variants (all sizes, formats, watermark combinations) for the given source image.
Programmatic URL generation
Generate optimized image URLs from PHP code (e.g. for API responses, emails):
use IdSign\ImageBundle\Service\ImageUrlGenerator; class ImageApiController { public function __construct( private readonly ImageUrlGenerator $imageUrlGenerator, ) {} public function getImageUrl(Request $request): JsonResponse { // Negotiate format from Accept header (AVIF > WebP > original) $url = $this->imageUrlGenerator->generateFromRequest($request, 'uploads/photo.jpg', 800, 600, 'cover'); // Or specify a format explicitly: a configured one or the source's fallback, anything else throws $url = $this->imageUrlGenerator->generate('uploads/photo.jpg', 800, 600, 'cover', 80, 'webp'); return new JsonResponse(['image' => $url]); } }
Both methods follow the rules of <twig:Image>: width must be between 1 and max_width (an
InvalidArgumentException otherwise — the controller trusts the signature, so the cap sits where URLs are made),
height is a processing dimension only together with fit (without fit the server resizes by width and keeps the
aspect ratio), and a format the controller would not serve throws. quality takes an int or a {format: quality}
map like the component attribute; omitted, it is the configured quality of the resulting format (format_quality,
then default_quality), resolved after negotiation. Unlike the component, scale-down keeps the requested width
instead of the source dimensions, so the URL can be a separate cache variant.
Twig function image_url()
For contexts where you need a single image URL instead of a full <picture> tag — og tags, emails, JSON-LD, etc.
For an .svg source it returns the passthrough URL (/_image/icons/logo.svg), like <twig:Image>; the same holds for
ImageUrlGenerator. format must be one the controller serves — one of the configured formats or the source's
fallback format (see URL security); anything else throws an InvalidArgumentException instead of
returning a URL that answers 404. Without format and without a request to negotiate from (console command, e-mail
rendered in a worker) the URL is webp, or the source's fallback format when webp is not in formats.
{# Format negotiated from current request (AVIF > WebP > original) #} {{ image_url('uploads/photo.jpg', 800) }} {# Explicit format (best for og tags, emails — no content negotiation) #} {{ image_url('uploads/photo.jpg', 800, format='webp') }} {# og:image / twitter:image — WebP whatever the source format, see "Share images" below #} {{ image_url(article.image, 1200, 630, 'cover', format='webp') }} {# Full control #} {{ image_url('uploads/photo.jpg', 800, height=600, fit='cover', quality=90, format='avif', watermark='copyright') }} {# Auto-calculate height from aspect ratio #} {{ image_url('uploads/photo.jpg', 800, autoDimensions=true, format='webp') }}
Share images (og:image, twitter:image): use format='webp'. As of late 2024 WebP previews render on every
major platform — Facebook, X, LinkedIn, Slack, Discord, WhatsApp, iMessage, Threads, Bluesky, Mastodon, Pinterest —
while AVIF is dropped by X, LinkedIn, Slack, Discord and iMessage. Do not reach for JPEG out of habit: the controller
generates only the configured formats and the source's own format, so format='jpeg' throws for a WebP or PNG source
(the gate that keeps one signed URL from producing a lossless PNG, see URL security). If a platform
you target really needs JPEG, add jpeg to formats — at the price of a third <source> in every <picture> — and
check the result in the Facebook Sharing Debugger and the LinkedIn Post Inspector, which cache previews for days.
src and width are positional and required. Every other parameter is optional and passed by name in any order
(Twig named arguments, matching the names in the table), or positionally in the table's order.
| Parameter | Type | Required | Description |
|---|---|---|---|
src |
string | yes | Source path, optionally @source/-prefixed |
width |
int | yes | Output width in pixels |
height |
int | no | Processing height, used only together with fit (auto-calculated if autoDimensions enabled) |
fit |
string | no | cover, contain, or scale-down |
quality |
int|map | no | Output quality 1-100, or a {format: quality} map applied to the negotiated or given format; omitted: format_quality, then default_quality |
format |
string | no | A configured format or the source's fallback (jpeg, png, …), else throws. Omitted: negotiated from the request, webp without one |
watermark |
string|false | no | Profile name, false to disable, omit for global default |
autoDimensions |
bool | no | Auto-calculate height from aspect ratio (overrides global config) |
lossless |
bool | no | Lossless encoding for WebP/AVIF output (overrides global config) |
Upgrading
See UPGRADE.md for what to check or change when you upgrade from an earlier version — breaking changes, configuration that now fails the container build, and cache entries to purge.
Development
# Install dependencies composer install # Run tests vendor/bin/phpunit # Static analysis vendor/bin/phpstan analyse # Code style fix vendor/bin/php-cs-fixer fix
Docker test matrix
Run tests across all supported PHP versions using Docker:
make test-matrix # Run tests on PHP 8.2, 8.3, 8.4, 8.5 make test # Run tests on PHP 8.4 (default) make test PHP_VERSION=8.2 # Run tests on a specific PHP version make phpstan # Run PHPStan analysis make cs-fix # Run PHP CS Fixer (dry-run)
License
MIT
resources/sRGB2014.icc is the International Color Consortium's sRGB profile (Copyright International Color
Consortium, 2015), distributed under the ICC's terms: it may be copied, distributed, embedded, made, used, and sold
without restriction; altered versions must have the original identification and copyright information removed.
resources/AdobeCompat-v2.icc (an Adobe RGB-compatible profile) and the ICC profiles embedded in the test fixtures
tests/Fixtures/p3.jpg and tests/Fixtures/srgb-tagged.jpg come from
Compact-ICC-Profiles (CC0 1.0).