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, Cloudinary, imgix 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, Cloudinary, imgix
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 |
cloudinary |
composer require symfony/ux-cloudinary-image |
cloudinary://my-cloud?origin=https://example.com |
imgix |
composer require symfony/ux-imgix-image |
imgix://my-source.imgix.net |
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 | Cloudinary | imgix | KeyCDN |
|---|---|---|---|---|
width |
width |
w |
w |
width |
height |
height |
h |
h |
height |
format |
format |
f |
fm |
format |
format: auto |
format=auto |
f_auto |
auto=format |
|
quality |
quality |
q |
q |
quality |
fit: Fit::Cover |
fit=cover |
c_fill |
fit=crop |
fit=cover |
fit: Fit::Contain |
fit=contain |
c_fit |
fit=clip |
fit=contain |
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 |
| Cloudinary | avif, webp, jpeg, png |
Yes |
| imgix | avif, webp, jpeg, png |
Yes |
| KeyCDN | webp, jpeg, png |
No |
Cloudflare, Cloudinary and imgix negotiate natively, through their own
format=auto, f_auto and auto=format. 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.
Cloudinary
Cloudinary transforms images either from your own origin or from your Cloudinary media library.
1
$ composer require symfony/ux-cloudinary-image
1 2
# .env.prod
UX_IMAGE_DSN=cloudinary://my-cloud?origin=https://example.com
The host is your Cloudinary cloud name. The origin option picks how
images are delivered:
- With
origin, the provider uses fetch delivery.originis the base URL your application serves the original images from, and everysrcis appended to it. Cloudinary downloads each original from there, then transforms and caches it. No upload to Cloudinary is needed. - Without
origin(cloudinary://my-cloud), the provider uses upload delivery, andsrcis the public ID of an image in your media library, without its leading slash. To keep the samesrcas your application, set up an auto-upload mapping from a folder to your origin: Cloudinary then copies each original into the media library on the first request.
Fetch delivery only works when the "Fetched URL" box is cleared in the
"Restricted media types" section of the account's security settings.
Cloudinary ticks this box by itself when an account does not use fetch within
7 days of its creation. If the box is ticked, sign every URL with the
api_secret option described below.
An account can require every delivery URL to be signed, with the
Strict transformations setting. Give the account's API secret as the
api_secret DSN option, and every generated URL then carries the matching
s--…-- signature:
1 2 3 4 5 6
# .env.prod
CLOUDINARY_API_SECRET="<override me>"
UX_IMAGE_DSN=cloudinary://my-cloud?origin=https://example.com&api_secret=${CLOUDINARY_API_SECRET}
# .env.prod.local
CLOUDINARY_API_SECRET=the-api-secret
Warning
The API secret signs every URL, and it also grants full access to your Cloudinary account through its API. Keep it out of the files you commit, and store it the way you store your other secrets.
Extra operations, forwarded as-is with Cloudinary's own short names: a
(angle), b (background), bo (border), co (color), d (default
image), dpr, e (effect), fl (flags), g (gravity), o
(opacity), r (radius), t (named transformation), x, y, z
(zoom). A value can contain a :, like {e: 'sharpen:100'}, but not a
/ or a ,. See Cloudinary's own transformation reference for what
each one does.
imgix
imgix transforms images from a source you configure in its dashboard, through query string parameters appended to the source's URL.
1
$ composer require symfony/ux-imgix-image
1 2
# .env.prod
UX_IMAGE_DSN=imgix://my-source.imgix.net
The host is the domain of your imgix source, either its imgix.net
subdomain or a custom domain. With a Web Folder source pointing at your
origin, src stays the path your application serves the image from. With a
storage source, like Amazon S3, src is the path of the image in the
bucket.
When the source has secure URLs enabled, give its token as the sign_key
option, and every generated URL then carries the matching s parameter.
Keep the token out of the files you commit, for example with a placeholder
that a .env.prod.local file overrides:
1 2 3 4 5 6
# .env.prod
IMGIX_SIGN_KEY="<override me>"
UX_IMAGE_DSN=imgix://my-source.imgix.net?sign_key=${IMGIX_SIGN_KEY}
# .env.prod.local
IMGIX_SIGN_KEY=the-token
Warning
The token signs every URL: anyone holding it can request any transformation of any image of the source. Store it the way you store your other secrets.
Extra operations, forwarded as-is: auto, bg, blur, border,
bri, con, crop, dpr, exp, fill, fill-color,
flip, fp-x, fp-y, fp-z, gam, high, invert,
monochrome, orient, pad, rect, rot, sat, sepia,
shad, sharp, trim, usm, vib. An auto operation, like
compress, is joined to the auto=format of automatic format
negotiation. See imgix's own rendering API 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.
A zone can require every image processing request to be signed, with the
Secure Token setting. Give the zone's Secure Token Key as the
secure_token DSN option, and every generated URL then carries the
matching token parameter:
1 2 3 4 5 6
# .env.prod
UX_IMAGE_DSN=keycdn://myzone.kxcdn.com?secure_token=%env(KEYCDN_SECURE_TOKEN)%
KEYCDN_SECURE_TOKEN=<override me>
# .env.prod.local
KEYCDN_SECURE_TOKEN=the-secure-token-key
Warning
The Secure Token Key signs every URL, so anyone holding it can have your zone transform any image. Keep it out of the files you commit, and store it the way you store your other secrets.
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.
- Cloudinary can require signed URLs on the account, with
Strict transformations. Turn it on, and give the API secret to the
provider with the
api_secretDSN option: the account then serves only the URLs your application generated. - With fetch delivery, anyone can also ask your Cloudinary account to fetch an image from any other domain. Restrict the allowed fetch domains to your origin.
- imgix can require signed URLs on a source, with secure URLs. Turn it on,
and give the source's token to the provider with the
sign_keyDSN option: the source then serves only the URLs your application generated. - KeyCDN can require a signed token on the zone, with Secure Token. Turn
it on, and give the key to the provider with the
secure_tokenDSN option: the zone then serves only the URLs your application generated.
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.