Hi 👋🏻
I believe it's time to open a RFC to describe what we really want for our new package UX Image, since we already have 4 PRs trying to implement it:
Prior art
The JS ecosystem has shipped this for years. Next.js has <Image> on top of Vercel's image optimization, Nuxt has <NuxtImg> with one pluggable provider per image service, and unpic generalizes the idea into a framework-agnostic library. They all solve the same problem: write a single component and get an image sized for the viewport that asked for it, instead of shipping a 4000px hero to a phone. Symfony has no equivalent. UX Image follows Nuxt's provider design.
Where we are
#3188 (+5492, targets 2.x) is the oldest attempt: <twig:img> and <twig:picture> with viewport-based widths (100vw md:80vw), ratio cascading, focal point, preload injection, and liip/imagine-bundle as a hard requirement. It is unmaintained and never moved to 3.x. But the shape was right: the component declares what it wants, something else does the transformation.
#3549 (+1793) is a slimmer redo that answered most of the feedback on #3188. It introduced the piece I care about: a provider abstraction, with ProviderInterface, a ux_image.provider tag, a default_provider option, a PassThroughProvider for local files and a UrlPatternProvider for a generic CDN URL template. This is very close to what I would like. What is missing is a provider that actually transforms anything locally, since PassThroughProvider returns the URL untouched.
#3765 (+1710) adds <twig:ux:img>, <twig:ux:picture> and <twig:ux:source>, plus ux_img() and ux_picture(). Validation is careful and HTML-spec accurate: no mixed w/x descriptors, sizes="auto" requires loading="lazy", enum attributes checked. It renders and validates markup the caller has already built by hand. There is no transformation, so it does not answer the reason people would install an image package.
#3768 (+20341 across 100 files) goes all the way in the other direction: inspect, resize and encode with GD or Intervention, publish variants to local or Flysystem storage, persist an immutable ImageAsset, render <picture> from that metadata. It is thorough and the test suite is serious. But 27% of src/ reimplements what Intervention Image already ships, and the CDN builders only decorate the paths of variants that were already generated locally. There is no URL-only mode: a Cloudinary setup still runs the full GD pipeline first, then asks Cloudinary to re-transform a derivative.
Scope
Storing the file is not our job. The application owns the upload, wherever it comes from (Symfony Forms, UX Upload, a console import, an existing bucket) and hands us a path. UX Image turns that path plus a requested size into URLs, and renders the markup around them. Nothing else.
That already removes storage backends, write sessions, Doctrine types and regeneration commands from the package.
The images worth optimizing are the ones the application doesn't control: uploads in a blog post, avatars on a forum, a product catalogue. Their size, format and weight come from whoever uploaded them.
Images sitting in assets/images/ are the opposite: known at build time, already sized by the developer, and already versioned by AssetMapper, Encore, or Reprise ✨ . A plain <img> is the better answer there. Icons belong to UX Icons, not here.
What the package should provide
The value of UX Image is the providers, not the pipeline.
Every image service already transforms by URL, and they all take the same handful of parameters:
| Provider |
Example |
| Glide |
/img/hero.jpg?w=800&h=600&fit=crop&fm=webp&q=85 |
| Cloudflare Images |
/cdn-cgi/image/width=800,height=600,fit=cover,format=auto,quality=85/hero.jpg |
| KeyCDN |
/hero.jpg?width=800&height=600&fit=cover&format=webp&quality=85 |
Different spelling, same tuple: (path, width, height, fit, format, quality). A provider maps that tuple to a URL: roughly 60 lines each.
I would ship these three in v1: Glide for local, Cloudflare Images and KeyCDN to prove the contract holds across two different URL syntaxes. Cloudinary, imgix, Vercel, Netlify and the rest can live in userland behind ProviderInterface, and we add the ones people actually ask for.
Two reasons to go this way:
- Less code. No decoding, no encoding, no EXIF handling, no codec capability matrix. That code already exists, is maintained by someone else, and better tested than anything we would write.
- Better performance. Nothing to process when a file is uploaded, and changing a breakpoint changes a URL instead of triggering a regeneration batch over the whole database.
URL-only does not mean CDN-only
This is the part I want to make explicit, because "URL-based" usually reads as "you now need a paid CDN".
Glide is a League package that is exactly a URL-driven image server: ?w=800&h=600&fit=crop&fm=webp&q=85, with signed URLs and its own cache. It is the local provider, and it satisfies the same contract as the hosted ones.
That symmetry has a price, and the RFC should say so rather than let someone discover it in review. Glide is the only one of the three that runs inside your application instead of someone else's, so its bridge ships a controller, a route and a response adapter, and pulls in a real image library transitively (Intervention Image 4, via Glide 4). It also exposes a public resize endpoint: without a signing key the server actually validates, anyone can request ?w=1, ?w=2, ?w=3 and mint a fresh cache entry every time. Glide has signing for exactly that reason; a bridge that generates signatures without checking them is worse than no signing at all, because it reads as protection.
None of that changes the contract. Same (path, width, format, quality, fit) tuple, same ProviderInterface, same env var to switch. But the local option is a small server, not a URL template, and pretending otherwise would make the "no CDN account required" claim thinner than it is.
So the provider becomes a configuration choice, switchable per environment through an env var:
# config/packages/ux_image.yaml
ux_image:
provider: '%env(UX_IMAGE_PROVIDER)%' # glide locally, cloudflare in prod
providers:
glide:
source: '%kernel.project_dir%/public/uploads'
cache: '%kernel.cache_dir%/ux_image'
cloudflare:
base_url: 'https://example.com'
No application code changes between dev and prod, and no CDN account required to get started.
Rendering
The provider gives the URLs, TwigComponent gives the attributes:
<twig:ux:image src="/uploads/hero.jpg" alt="Hero" sizes="(min-width: 64rem) 50vw, 100vw" class="rounded" />
The component asks the provider for one URL per requested width, builds srcset and sizes, and lets ComponentAttributes render everything else. We should not write a single htmlspecialchars() call: twig/html-extra already handles merging, escaping, null/false, aria-*, data-*, style and BackedEnum.
<img> only covers the common case when the provider negotiates the format itself. Cloudflare does, through format=auto. Glide does not: fm accepts jpg, pjpg, png, gif, webp, avif and heic, with no auto value. KeyCDN does not either, and has no AVIF at all.
So the contract has to expose whether a provider negotiates, and the renderer emits a <picture> with one <source type> per format when it does not. For Glide we own the controller, so it can resolve the format from the Accept header and answer with Vary: Accept, which puts it back on par with Cloudflare.
<picture> is also what art direction needs, when the crop itself changes with the viewport. That part can wait.
Open questions
How do we express the requested widths? I would rather not make people hand-write a list of pixel values on every component. unpic answers this with a layout prop and derives the candidates from it: fixed (exact width and height, 1x and 2x), constrained (a maximum width, scaling down on smaller screens) and fullWidth (viewport width, height only). The author describes the role of the image, the package picks the ladder and writes sizes. The alternative is an explicit widths list, configured globally with a per-component override. I lean towards the first, but have no strong opinion yet.
Signed URLs. Glide and most CDNs support them, but the key handling differs enough that it probably deserves its own contract rather than a secret option bolted onto every provider.
Thoughts?
Hi 👋🏻
I believe it's time to open a RFC to describe what we really want for our new package UX Image, since we already have 4 PRs trying to implement it:
Prior art
The JS ecosystem has shipped this for years. Next.js has
<Image>on top of Vercel's image optimization, Nuxt has<NuxtImg>with one pluggable provider per image service, and unpic generalizes the idea into a framework-agnostic library. They all solve the same problem: write a single component and get an image sized for the viewport that asked for it, instead of shipping a 4000px hero to a phone. Symfony has no equivalent. UX Image follows Nuxt's provider design.Where we are
#3188 (+5492, targets 2.x) is the oldest attempt:
<twig:img>and<twig:picture>with viewport-based widths (100vw md:80vw), ratio cascading, focal point, preload injection, andliip/imagine-bundleas a hard requirement. It is unmaintained and never moved to 3.x. But the shape was right: the component declares what it wants, something else does the transformation.#3549 (+1793) is a slimmer redo that answered most of the feedback on #3188. It introduced the piece I care about: a provider abstraction, with
ProviderInterface, aux_image.providertag, adefault_provideroption, aPassThroughProviderfor local files and aUrlPatternProviderfor a generic CDN URL template. This is very close to what I would like. What is missing is a provider that actually transforms anything locally, sincePassThroughProviderreturns the URL untouched.#3765 (+1710) adds
<twig:ux:img>,<twig:ux:picture>and<twig:ux:source>, plusux_img()andux_picture(). Validation is careful and HTML-spec accurate: no mixedw/xdescriptors,sizes="auto"requiresloading="lazy", enum attributes checked. It renders and validates markup the caller has already built by hand. There is no transformation, so it does not answer the reason people would install an image package.#3768 (+20341 across 100 files) goes all the way in the other direction: inspect, resize and encode with GD or Intervention, publish variants to local or Flysystem storage, persist an immutable
ImageAsset, render<picture>from that metadata. It is thorough and the test suite is serious. But 27% ofsrc/reimplements what Intervention Image already ships, and the CDN builders only decorate the paths of variants that were already generated locally. There is no URL-only mode: a Cloudinary setup still runs the full GD pipeline first, then asks Cloudinary to re-transform a derivative.Scope
Storing the file is not our job. The application owns the upload, wherever it comes from (Symfony Forms, UX Upload, a console import, an existing bucket) and hands us a path. UX Image turns that path plus a requested size into URLs, and renders the markup around them. Nothing else.
That already removes storage backends, write sessions, Doctrine types and regeneration commands from the package.
The images worth optimizing are the ones the application doesn't control: uploads in a blog post, avatars on a forum, a product catalogue. Their size, format and weight come from whoever uploaded them.
Images sitting in
assets/images/are the opposite: known at build time, already sized by the developer, and already versioned by AssetMapper, Encore, or Reprise ✨ . A plain<img>is the better answer there. Icons belong to UX Icons, not here.What the package should provide
The value of UX Image is the providers, not the pipeline.
Every image service already transforms by URL, and they all take the same handful of parameters:
/img/hero.jpg?w=800&h=600&fit=crop&fm=webp&q=85/cdn-cgi/image/width=800,height=600,fit=cover,format=auto,quality=85/hero.jpg/hero.jpg?width=800&height=600&fit=cover&format=webp&quality=85Different spelling, same tuple:
(path, width, height, fit, format, quality). A provider maps that tuple to a URL: roughly 60 lines each.I would ship these three in v1: Glide for local, Cloudflare Images and KeyCDN to prove the contract holds across two different URL syntaxes. Cloudinary, imgix, Vercel, Netlify and the rest can live in userland behind
ProviderInterface, and we add the ones people actually ask for.Two reasons to go this way:
URL-only does not mean CDN-only
This is the part I want to make explicit, because "URL-based" usually reads as "you now need a paid CDN".
Glide is a League package that is exactly a URL-driven image server:
?w=800&h=600&fit=crop&fm=webp&q=85, with signed URLs and its own cache. It is the local provider, and it satisfies the same contract as the hosted ones.That symmetry has a price, and the RFC should say so rather than let someone discover it in review. Glide is the only one of the three that runs inside your application instead of someone else's, so its bridge ships a controller, a route and a response adapter, and pulls in a real image library transitively (Intervention Image 4, via Glide 4). It also exposes a public resize endpoint: without a signing key the server actually validates, anyone can request
?w=1,?w=2,?w=3and mint a fresh cache entry every time. Glide has signing for exactly that reason; a bridge that generates signatures without checking them is worse than no signing at all, because it reads as protection.None of that changes the contract. Same
(path, width, format, quality, fit)tuple, sameProviderInterface, same env var to switch. But the local option is a small server, not a URL template, and pretending otherwise would make the "no CDN account required" claim thinner than it is.So the provider becomes a configuration choice, switchable per environment through an env var:
No application code changes between dev and prod, and no CDN account required to get started.
Rendering
The provider gives the URLs, TwigComponent gives the attributes:
The component asks the provider for one URL per requested width, builds
srcsetandsizes, and letsComponentAttributesrender everything else. We should not write a singlehtmlspecialchars()call:twig/html-extraalready handles merging, escaping,null/false,aria-*,data-*,styleandBackedEnum.<img>only covers the common case when the provider negotiates the format itself. Cloudflare does, throughformat=auto. Glide does not:fmacceptsjpg,pjpg,png,gif,webp,avifandheic, with noautovalue. KeyCDN does not either, and has no AVIF at all.So the contract has to expose whether a provider negotiates, and the renderer emits a
<picture>with one<source type>per format when it does not. For Glide we own the controller, so it can resolve the format from theAcceptheader and answer withVary: Accept, which puts it back on par with Cloudflare.<picture>is also what art direction needs, when the crop itself changes with the viewport. That part can wait.Open questions
How do we express the requested widths? I would rather not make people hand-write a list of pixel values on every component. unpic answers this with a
layoutprop and derives the candidates from it:fixed(exactwidthandheight, 1x and 2x),constrained(a maximumwidth, scaling down on smaller screens) andfullWidth(viewport width,heightonly). The author describes the role of the image, the package picks the ladder and writessizes. The alternative is an explicitwidthslist, configured globally with a per-component override. I lean towards the first, but have no strong opinion yet.Signed URLs. Glide and most CDNs support them, but the key handling differs enough that it probably deserves its own contract rather than a
secretoption bolted onto every provider.Thoughts?