symfony/ux-lazy-image
Symfony UX LazyImage makes lazy-loading images simple in Symfony apps. It offers a Twig component for native browser lazy loading, optional placeholders, and performance-friendly rendering that defers offscreen images, reduces initial page weight, and helps improve Core Web Vitals.
Install the package
composer require symfony/ux-lazy-image
Requires a recent Symfony (7.1+). It is a Symfony UX split-repo package — developed in the symfony/ux monorepo and auto-split here, so it is officially maintained even though the repo shows low star count.
Wire up the JavaScript (once per project)
php bin/console importmap:require @symfony/ux-lazy-imagenpm install @symfony/ux-lazy-image, then import it in assets/app.js or let controllers.json auto-register the lazy-image Stimulus controller.window.Stimulus), otherwise images will render as unstyled placeholders or not load.Look at these files first
vendor/symfony/ux-lazy-image/templates/components/LazyImage.html.twig (the markup contract)src/Twig/LazyImageExtension.php (the ux_lazy_image() function)assets/src/lazy_image_controller.js (the loading / blurhash logic)First use case — replace heavy <img> tags in a list or gallery:
<twig:ux:lazy-image
src="{{ asset('images/hero.jpg') }}"
alt="Hero"
width="1200"
height="630"
/>
<twig:ux:lazy-image
src="/images/photo.jpg"
alt="A photo"
width="600"
height="400"
class="rounded shadow"
/>
Any extra HTML attribute (class, id, style, loading, fetchpriority, decoding, data-*) is passed through to the rendered <img> — use this for CSP nonces or analytics hooks.
{{ ux_lazy_image('/images/' ~ image.filename, image.alt, {
width: image.width,
height: image.height,
class: 'card-img-top',
srcset: '/images/small.jpg 320w, /images/large.jpg 1024w',
sizes: '(max-width: 640px) 100vw, 640px',
}) }}
Use this when you build attributes programmatically (e.g. inside an entity loop or a macro).
Generate the hash once (via blurha.sh or the kornrunner/blurhash PHP library), store it on the entity/asset, and pass it in:
<twig:ux:lazy-image
src="{{ asset('uploads/' ~ image.path) }}"
alt="{{ image.alt }}"
blurhash="{{ image.blurhash }}"
width="{{ image.width }}"
height="{{ image.height }}"
/>
The Stimulus controller paints the canvas placeholder and then swaps in the real image once it intersects the viewport. Because hashes are precomputed and stored, no per-request compute happens server-side — a good pattern is a blurhash column populated in an upload listener or a console command.
The typical workflow is a Twig loop where size and srcset come from your image variant service (LiipImagineBundle, Glide, or a custom URL builder). Combine with width/height from the database so the browser reserves layout space and CLS stays at zero.
templates/components/LazyImage.html.twig in your app to change wrapper markup (e.g. add a <picture> element).data-controller value, or extend the shipped one via a Stimulus subclass if you need extra behavior (custom placeholder blur, fade-in animation).Symfony\UX\LazyImage\Twig\LazyImageExtension if you need to inject default attributes (e.g. always add decoding="async").Always use asset() or importmap-managed URLs for the src; hardcoded paths break under versions/CDN base paths. For CDN-served images, pass the full CDN URL from config rather than composing it in Twig.
<img> is populated by the Stimulus controller; if the controller is not registered (missing importmap:require, missing Encore import, or controllers.json not loaded), images stay invisible or unswapped. Check DevTools for data-controller="lazy-image" on the wrapper.^[0-9A-Za-z#$%*+,-.:;=?@\[\]^_{|}~]{1,}$) and fall back to a solid color or no hash.width and height are not automatic. Always pass intrinsic dimensions — the component does not sniff them. Without them you reintroduce layout shift, defeating much of the benefit.loading="eager" fetchpriority="high"); lazy-loading it hurts Core Web Vitals. Use the component selectively.<img>-appropriate attributes belong on the component; things like role or aria-* on a wrapper may need a template override. Inspect the rendered DOM once to confirm where each attribute lands.php bin/console asset-map:compile (and importmap:require again after upgrades); with Encore, run npm run build. Forgetting this is the #1 cause of "it works in dev, images disappear in prod".img-src data:. Test in a hardened staging environment.symfony/ux-lazy-image in composer.json and read upgrade notes — the component's prop names or template internals may still shift between minor versions.data-controller="lazy-image" in the DOM? (2) is @symfony/ux-lazy-image in importmap.php / package.json? (3) any console error from blurhash? (4) is the image element actually inside the viewport at load (IntersectionObserver won't fire for off-screen ones without scroll)? (5) does the URL resolve (404s render as broken placeholders).class/decoding everywhere, decorate the Twig extension or set defaults in a base template override so future attribute policy changes stay in one place.How can I help you explore Laravel packages today?