Skip to content

Symfony UX Image

Edit this page

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 own format=auto; formats then 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\UX\Image\Renderer\LayoutResolver::DEFAULT_RESOLUTIONS, 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 both width and height given, style: object-fit: cover; width: 100%; aspect-ratio: {width/height}; height: auto. With only height (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's format=auto, that <img> still gets a modern format. When it cannot, like KeyCDN, the <img> uses the last entry of the formats option.
  • ux_picture() and <twig:ux:picture> always render a <picture>, with one <source type="image/…" srcset="…"> per entry of the formats option 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 ignore Vary: 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\UX\Image\Provider\ProviderInterface and is built from the DSN by a factory implementing Symfony\UX\Image\Provider\ProviderFactoryInterface. Extending Symfony\UX\Image\Provider\AbstractProviderFactory 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.

This work, including the code samples, is licensed under a Creative Commons BY-SA 3.0 license.
TOC
    Version