Symfony UX Image
EXPERIMENTAL This component is currently experimental and is likely to change, or even change drastically.
Symfony UX Image renders responsive images in Symfony applications by delegating every transformation to a URL-based image provider, such as Cloudflare or KeyCDN. It is part of the Symfony UX initiative.
The package never decodes, resizes or encodes an image itself. Every
transformation is expressed as a URL, built by whichever provider is
active; the package's own job is generating that URL, a srcset, a
sizes attribute and a layout style.
Installation
Install the bundle using Composer and Symfony Flex:
1
$ composer require symfony/ux-image
Then install one of the provider bridges, for example Cloudflare:
1
$ composer require symfony/ux-cloudflare-image
The Twig functions and the <twig:ux:image> and <twig:ux:picture>
components need symfony/ux-twig-component. Without it, the bundle still
provides the ImageUrlGenerator service (see
Getting a single URL), which is enough for an API or
a worker:
1
$ composer require symfony/ux-twig-component
Rendering an image
The <twig:ux:image> component renders a single image:
1
<twig:ux:image src="/uploads/hero.jpg" alt="Hero" width="800" height="450" />
src is the public URL path of the original image, as your application
serves it. Every provider reads it the same way: Cloudflare and KeyCDN fetch it
from your origin, and null:// renders it as is. Switching providers
therefore never changes your templates.
For example, with UX_IMAGE_DSN=cloudflare://cdn.example.com, this template:
1
<twig:ux:image src="/uploads/hero.jpg" alt="Hero" width="400" height="300" />
renders the following HTML (line breaks added for readability):
1 2 3 4 5 6 7 8 9 10 11 12 13 14
<img
src="https://cdn.example.com/cdn-cgi/image/width=400,height=300,fit=cover,format=auto/uploads/hero.jpg"
alt="Hero"
srcset="
https://cdn.example.com/cdn-cgi/image/width=400,height=300,fit=cover,format=auto/uploads/hero.jpg 400w,
https://cdn.example.com/cdn-cgi/image/width=640,height=480,fit=cover,format=auto/uploads/hero.jpg 640w,
https://cdn.example.com/cdn-cgi/image/width=750,height=563,fit=cover,format=auto/uploads/hero.jpg 750w,
https://cdn.example.com/cdn-cgi/image/width=800,height=600,fit=cover,format=auto/uploads/hero.jpg 800w"
sizes="(min-width: 400px) 400px, 100vw"
loading="lazy"
width="400"
height="300"
style="object-fit: cover; max-width: 400px; max-height: 300px; aspect-ratio: 400 / 300; width: 100%; height: auto;"
/>
The browser picks the smallest candidate that covers the displayed width at
the screen's pixel density, and Cloudflare picks the output format from the
request's Accept header.
The equivalent ux_image() Twig function is available for programmatic use,
for example when the source path is only known inside a Twig macro:
1
{{ ux_image('/uploads/hero.jpg', 'Hero', {width: 800, height: 450}) }}
Both accept the same rendering options: the component exposes them as props,
the function takes them as its third, associative-array argument, keyed by
prop name in camelCase (objectFit, not object-fit). An unknown key
there throws an InvalidArgumentException.
Both accept HTML attributes too: the component takes them next to its props, the function as its fourth argument:
1
{{ ux_image('/uploads/hero.jpg', 'Hero', {width: 800}, {class: 'rounded', loading: 'eager'}) }}
Props
| Prop | Type | Default |
|---|---|---|
src |
string |
required |
alt |
string |
required |
layout |
fixed|constrained|full-width |
constrained |
width |
int|null |
null |
height |
int|null |
null |
fit |
cover|contain|null |
cover or null, see below |
format |
string|null |
null |
quality |
int|null (1 to 100) |
null |
priority |
bool |
false |
object-fit |
string|null |
the fit value |
breakpoints |
int[]|null |
the resolutions option |
operations |
array<string, array> |
{} |
preset |
string|null |
null |
layout and its breakpoints, sizes and generated style are
covered under Layout and rendering.
priority sets loading="eager" fetchpriority="high"; without it, an
image only gets loading="lazy".
preset applies a named set of transformations from the configuration (see
The presets option).
Caution
Images rendered by the component and by ux_image() are lazy-loaded by
default. If an image is likely to be the page's Largest Contentful Paint (a
hero image, the first product photo), set priority on it; otherwise the
browser waits before loading it, which delays the LCP, and Lighthouse will
report it. Here is an example:
1
<twig:ux:image src="/uploads/hero.jpg" alt="Hero" width="1200" height="600" priority />
fit decides how the provider reshapes the source image into the requested
width x height. cover fills the box and crops the excess, and
contain fits the whole image inside it. Both enlarge a source that is
smaller than the box. fit defaults to cover when both width and
height are set, and it only has an effect then, since without both there
is no target box to fit into. It only produces a visible difference when the
source image's aspect ratio differs from the requested one: at equal ratios
there is nothing to crop and nothing to letterbox, so both modes come out
identical.
The generated object-fit follows fit (cover -> cover,
contain -> contain), so the browser doesn't redo a crop the provider
was asked to avoid. Without that, a contain image would be cropped back to
fill its box by CSS, and fit would never be observable.
format pins the output format for this one image, in place of both the
provider's own negotiation and the per-format <picture> fallbacks: the
<img> is rendered in that format, and a <picture> gets no <source>. A format the active provider
cannot produce throws an InvalidArgumentException naming its supported
list.
Any attribute not listed above (class, data-*, a caller-supplied
style or sizes, …), like any attribute passed to ux_image(), is
passed through to the rendered <img> and wins over a generated one such as
loading. A caller-supplied style merges with the generated layout style
instead of replacing it; a caller-supplied sizes replaces the generated
value.
Provider-specific operations
Every provider accepts extra, provider-specific parameters beyond the common
width/height/fit/format/quality set (see
Providers for the full list per bridge). Pass them
through operations, keyed by provider name:
1 2
<twig:ux:image src="/uploads/hero.jpg" alt="Hero" width="800"
:operations="{cloudflare: {sharpen: 1}, keycdn: {sharpen: 10}}" />
At render time, only operations[activeProviderName] is read; the rest is
ignored. Keying by provider name is deliberate: the active DSN changes
between environments, and a flat, un-keyed gravity option would silently
vanish the moment the application switched from Cloudflare to another
provider. Passing an operation the active provider does not support throws
an InvalidArgumentException naming the provider's supported list. A key
that names no installed provider throws as well, so a typo such as
cloudfare fails in development rather than being silently ignored in
production.
Getting a single URL
Some places need one URL rather than a responsive image: an Open Graph image,
an email, an API response or a CSS background. The ux_image_url() Twig
function returns it:
1
<meta property="og:image" content="{{ absolute_url(ux_image_url('/uploads/hero.jpg', {width: 1200, height: 630})) }}">
It accepts width, height, fit, format, quality,
operations and preset, with the same meaning as the component's props.
Like the component, it defaults fit to cover when both width and
height are set. An unknown option throws an InvalidArgumentException.
In PHP, autowire Symfony\UX\Image\ImageUrlGenerator, which applies the
same validation:
1 2 3 4 5 6 7 8 9 10 11
use Symfony\UX\Image\ImageUrlGenerator;
public function __construct(
private ImageUrlGenerator $imageUrlGenerator,
) {
}
public function ogImage(): string
{
return $this->imageUrlGenerator->generate('/uploads/hero.jpg', width: 1200, height: 630);
}
Replacing an original
A transformation URL only depends on the original's path and on the
transformation parameters. When you replace an original under the same path,
the provider and the browsers keep serving the variants they already cached.
Give each version of an image its own path instead, for example with a hash or
a version in the filename (/uploads/hero.3f2a1c.jpg).
Configuration
Configuration is done in your config/packages/ux_image.yaml file:
1 2 3 4 5 6 7
# config/packages/ux_image.yaml
ux_image:
provider: '%env(resolve:UX_IMAGE_DSN)%'
formats: ['avif', 'webp', 'jpeg']
resolutions: [6016, 5120, 4480, 3840, 3200, 2560, 2048, 1920, 1668, 1280, 1080, 960, 828, 750, 640]
quality: null
presets: {}
The resolve: processor is required: a DSN may reference container
parameters such as %kernel.project_dir%, and parameter resolution does not
recurse into environment variable values on its own.
The provider DSN
The provider option is a DSN string that selects which bridge renders
your images, and configures it. Define it in your .env files so it can
change per environment:
1 2
# .env
UX_IMAGE_DSN=null://null
1 2
# .env.prod
UX_IMAGE_DSN=cloudflare://cdn.example.com
null://null is the default when no provider is configured. It needs no
bridge and transforms nothing: each image renders with its original URL, no
srcset and no sizes, but keeps its dimensions and layout style.
That makes it the provider to develop and test templates with when no CDN is
available.
Each bridge is only registered when its Composer package is actually installed. Install the one matching the scheme used in the DSN:
| Scheme | Install | DSN example |
|---|---|---|
keycdn |
composer require symfony/ux-keycdn-image |
keycdn://myzone.kxcdn.com |
cloudflare |
composer require symfony/ux-cloudflare-image |
cloudflare://cdn.example.com |
See Providers for what each DSN option means and how
transformation parameters map to that provider's own query string. A DSN
option the provider does not support throws an InvalidArgumentException
naming the options it does support.
The formats option
formats is the candidate output format list, in preference order. It is
intersected with the active provider's own supported formats (see
Providers), then:
ux_picture()renders one<source>per surviving entry (see Layout and rendering);ux_image()renders its single<img>in the last surviving entry, unless the provider negotiates the format itself, like Cloudflare with its ownformat=auto;formatsthen has no effect on it.
An empty intersection throws an exception naming both lists, so asking a provider that cannot encode AVIF to serve only AVIF fails at first render rather than serving the wrong format silently.
The default is ['avif', 'webp', 'jpeg']. Narrowing it is how an
application keeps a format off the wire.
The resolutions option
resolutions is the resolution ladder the constrained and
full-width layouts draw their srcset candidates from (see
The resolution ladder). It defaults to
Symfony, the common
screen widths ported from unpic.
Narrow it when your images never reach those sizes, or when you want fewer
candidates per srcset; a component can still override it for one image
through the breakpoints prop. Each candidate is one more variant the
provider generates and caches, so a shorter ladder also means fewer
transformations on your CDN.
The quality option
quality is the output quality, from 1 to 100, of every generated image
that does not set its own through the quality prop or option, or through
a preset. It defaults to null, which leaves the
quality to the provider.
The presets option
presets gives a name to a set of transformations, which templates then
apply through the preset prop or option:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
# config/packages/ux_image.yaml
ux_image:
presets:
thumbnail:
width: 200
height: 200
quality: 70
og:
width: 1200
height: 630
format: jpeg
vintage:
operations:
cloudflare: { saturation: 0.3, contrast: 1.2 }
1 2 3 4 5
<twig:ux:image src="/uploads/hero.jpg" alt="Hero" preset="thumbnail" />
{{ ux_image('/uploads/hero.jpg', 'Hero', {preset: 'vintage', width: 800}) }}
<meta property="og:image" content="{{ absolute_url(ux_image_url('/uploads/hero.jpg', {preset: 'og'})) }}">
In PHP, pass the preset name to ImageUrlGenerator::generate(), or to the
RenderOptions given to ImageRendererInterface:
1 2 3
$url = $this->imageUrlGenerator->generate('/uploads/hero.jpg', preset: 'og');
$image = $this->imageRenderer->render('/uploads/hero.jpg', 'Hero', new RenderOptions(preset: 'thumbnail'));
A preset holds only width, height, fit, format, quality and
operations. layout, breakpoints, object-fit and priority
describe where the image sits on the page, so they stay in the template.
A value set in the template, or passed to ImageUrlGenerator::generate(),
wins over the preset's value. Each value is replaced on its own. For example,
setting only width on a 200x200 preset keeps its height of 200, so the
aspect ratio changes. operations are merged per provider and per operation,
so a template can add an operation or change one. The global quality option
only applies when neither the template nor the preset sets one. A template
cannot remove a value from a preset. Define another preset instead.
An unknown preset name throws an InvalidArgumentException listing the
defined presets. A preset whose operations name a provider that is not
installed fails when the container is compiled, so a typo is caught even in an
environment whose active provider is null://.
Layout and rendering
The layout prop
layout decides the breakpoint ladder used to build srcset, the
sizes attribute, and the generated style. constrained is the
default.
fixed-
Breakpoints:
[width, 2 × width].sizes:{width}px.style:object-fit: cover; width: {width}px; height: {height}px. constrained(the default)-
Breakpoints:
[width, 2 × width, …ladder entries below 2 × width].sizes:(min-width: {width}px) {width}px, 100vw.style:object-fit: cover; max-width: {width}px; max-height: {height}px; aspect-ratio: {width/height}; width: 100%; height: auto. full-width-
Breakpoints: the full resolution ladder.
sizes:100vw. With bothwidthandheightgiven,style:object-fit: cover; width: 100%; aspect-ratio: {width/height}; height: auto. With onlyheight(no derivable ratio),style:object-fit: cover; width: 100%; height: {height}px.
Both constrained and full-width (when a ratio applies) declare
height: auto alongside aspect-ratio: the width/height HTML
attributes on <img> become a definite CSS height through the browser's
own presentational hint, which would otherwise make CSS ignore
aspect-ratio outright.
fixed and constrained require width; full-width requires
height. Passing neither throws an InvalidArgumentException.
The generated sizes assumes the image spans the whole viewport when the
viewport is narrower than its width. That is right for a full-width
container, not for a grid or a sidebar, where the browser would download a
candidate several times too wide. Pass your own sizes there:
1 2 3
{# three columns from 1024px, two from 640px, one below #}
<twig:ux:image src="/uploads/hero.jpg" alt="Hero" width="400" height="300"
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw" />
object-fit overrides the value derived from fit. It defaults to the
fit value itself, or to cover when no fit applies.
The resolution ladder
Unless breakpoints is passed explicitly, candidates for constrained
and full-width are drawn from the resolutions option, which defaults
to a descending ladder of common screen widths ported from unpic:
1
6016, 5120, 4480, 3840, 3200, 2560, 2048, 1920, 1668, 1280, 1080, 960, 828, 750, 640
constrained keeps only the entries below twice the requested width;
full-width keeps the whole ladder.
<img> or <picture>
The element is the caller's choice, never the provider's, so switching
UX_IMAGE_DSN never changes the markup:
ux_image()and<twig:ux:image>always render a single<img>. When the provider picks the output format from the request itself, like Cloudflare'sformat=auto, that<img>still gets a modern format. When it cannot, like KeyCDN, the<img>uses the last entry of theformatsoption.ux_picture()and<twig:ux:picture>always render a<picture>, with one<source type="image/…" srcset="…">per entry of theformatsoption intersected with the provider's supported formats, in that order, followed by the<img>as the last-resort fallback. Every source names its format in its URLs, which suits a provider without automatic negotiation, and caches that ignoreVary: Accept.
1 2 3
<twig:ux:picture src="/uploads/hero.jpg" alt="Hero" width="800" height="450" />
{{ ux_picture('/uploads/hero.jpg', 'Hero', {width: 800, height: 450}) }}
Both take the same props, options and attributes as <twig:ux:image> and
ux_image(). With the same Cloudflare DSN,
<twig:ux:picture src="/uploads/hero.jpg" alt="Hero" width="400" height="300" />
renders one <source> per configured format, then the <img> fallback:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
<picture>
<source type="image/avif" sizes="(min-width: 400px) 400px, 100vw" srcset="
https://cdn.example.com/cdn-cgi/image/width=400,height=300,fit=cover,format=avif/uploads/hero.jpg 400w,
https://cdn.example.com/cdn-cgi/image/width=640,height=480,fit=cover,format=avif/uploads/hero.jpg 640w,
https://cdn.example.com/cdn-cgi/image/width=750,height=563,fit=cover,format=avif/uploads/hero.jpg 750w,
https://cdn.example.com/cdn-cgi/image/width=800,height=600,fit=cover,format=avif/uploads/hero.jpg 800w" />
<source type="image/webp" sizes="(min-width: 400px) 400px, 100vw" srcset="
https://cdn.example.com/cdn-cgi/image/width=400,height=300,fit=cover,format=webp/uploads/hero.jpg 400w,
https://cdn.example.com/cdn-cgi/image/width=640,height=480,fit=cover,format=webp/uploads/hero.jpg 640w,
https://cdn.example.com/cdn-cgi/image/width=750,height=563,fit=cover,format=webp/uploads/hero.jpg 750w,
https://cdn.example.com/cdn-cgi/image/width=800,height=600,fit=cover,format=webp/uploads/hero.jpg 800w" />
<source type="image/jpeg" sizes="(min-width: 400px) 400px, 100vw" srcset="
https://cdn.example.com/cdn-cgi/image/width=400,height=300,fit=cover,format=jpeg/uploads/hero.jpg 400w,
https://cdn.example.com/cdn-cgi/image/width=640,height=480,fit=cover,format=jpeg/uploads/hero.jpg 640w,
https://cdn.example.com/cdn-cgi/image/width=750,height=563,fit=cover,format=jpeg/uploads/hero.jpg 750w,
https://cdn.example.com/cdn-cgi/image/width=800,height=600,fit=cover,format=jpeg/uploads/hero.jpg 800w" />
<img
src="https://cdn.example.com/cdn-cgi/image/width=400,height=300,fit=cover,format=jpeg/uploads/hero.jpg"
alt="Hero"
srcset="
https://cdn.example.com/cdn-cgi/image/width=400,height=300,fit=cover,format=jpeg/uploads/hero.jpg 400w,
https://cdn.example.com/cdn-cgi/image/width=640,height=480,fit=cover,format=jpeg/uploads/hero.jpg 640w,
https://cdn.example.com/cdn-cgi/image/width=750,height=563,fit=cover,format=jpeg/uploads/hero.jpg 750w,
https://cdn.example.com/cdn-cgi/image/width=800,height=600,fit=cover,format=jpeg/uploads/hero.jpg 800w"
sizes="(min-width: 400px) 400px, 100vw"
loading="lazy"
width="400"
height="300"
style="object-fit: cover; max-width: 400px; max-height: 300px; aspect-ratio: 400 / 300; width: 100%; height: auto;"
/>
</picture>
A format prop short-circuits the choice: it names one format, so there is
nothing left to fall back to. ux_image() renders its <img> in that
format, and ux_picture() renders a <picture> that only holds that
<img>.
The <picture> emitted here only ever carries per-format fallbacks. It
never carries a different crop per media query (art direction); that is out
of scope for this version.
Providers
Parameter mapping
Every ImageTransformation property maps to a provider-specific query
parameter:
ImageTransformation |
Cloudflare | KeyCDN |
|---|---|---|
width |
width |
width |
height |
height |
height |
format |
format |
format |
quality |
quality |
quality |
fit: Fit::Cover |
fit=cover |
fit=cover |
Fit::Contain maps to fit=contain on every provider.
operations (see
Provider-specific operations) is merged
into the generated URL verbatim, once resolved for the active provider.
Supported formats and negotiation
| Provider | Supported formats | Negotiates automatically? |
|---|---|---|
| Cloudflare | avif, webp, jpeg, png |
Yes |
| KeyCDN | webp, jpeg, png |
No |
Cloudflare negotiates natively, through its own format=auto. KeyCDN has
no automatic format negotiation at all, and no AVIF support either: with it,
ux_image() serves the last configured format KeyCDN supports, and
ux_picture() is the way to offer WebP to the browsers that accept it (see
or ).
Cloudflare
Cloudflare Image Resizing transforms images already served from your own
origin, through the /cdn-cgi/image/ URL path on a Cloudflare zone. No
image-processing server of your own is needed.
1
$ composer require symfony/ux-cloudflare-image
1 2
# .env.prod
UX_IMAGE_DSN=cloudflare://cdn.example.com
The host is the domain proxied by your Cloudflare zone; it must be the
domain your origin images are served from. Image transformations must be
enabled on that zone before /cdn-cgi/image/ URLs work — see
Enable transformations in the Cloudflare docs.
Extra operations, forwarded as-is: gravity, dpr, rotate,
trim, blur, brightness, contrast, gamma, saturation,
sharpen, background, border, anim, metadata,
onerror, compression. See Cloudflare's own options reference for
what each one does.
KeyCDN
KeyCDN Image Processing transforms images already served from your own origin, through query string parameters appended to your zone's URL.
1
$ composer require symfony/ux-keycdn-image
1 2
# .env.prod
UX_IMAGE_DSN=keycdn://myzone.kxcdn.com
The host is your KeyCDN zone.
Extra operations, forwarded as-is: position, enlarge, trim,
crop, bg, rotate, flip, flop, sharpen, blur,
gamma, grayscale, progressive, lossless, metadata. See
KeyCDN's own parameter reference for what each one does.
Protecting transformations
Anyone can request a transformation URL, with any size or quality, and each distinct URL is one more image the provider generates and caches:
- Cloudflare only transforms images from the zone that serves the transformations by default. Keep it that way unless you need other origins, see the source origins settings of Cloudflare Image Resizing.
- KeyCDN can require a signed token on the zone, with Secure Token. The KeyCDN provider does not generate tokens yet, so it cannot be used with a zone that requires them.
Writing your own provider
A provider implements Symfony and is
built from the DSN by a factory implementing
Symfony. Extending
Symfony gives the factory the
scheme matching. The factory declares the DSN options it reads in
configureOptions(), with the OptionsResolver component, and gets them
back, checked, from resolveOptions().
Register the factory with the ux_image.provider_factory tag, and a
provider attribute holding the provider's name, which is both its DSN
scheme and its operations key:
1 2 3 4 5
# config/services.yaml
services:
App\Image\MyCdnProviderFactory:
tags:
- { name: 'ux_image.provider_factory', provider: 'mycdn' }
Without the provider attribute, the factory still works, but
operations keys are no longer checked against the installed providers.
The package supports PHP 8.4 or later and Symfony 7.4 or 8.x.