Responsive Images
Pixel manipulation happens on the Image chain; the moment you want markup you call
img(), which resolves presentation intent into an
element you print. The sections below build up from a bare srcset
to art-directed <picture> elements.
Srcset
Generate width variants with widths(), then enter markup with img():
$el = $image->cover(800, 600)->widths(800, 600, 400)
->img(alt: 'Photo', sizes: '(min-width: 800px) 800px, 100vw');
echo $el;
<img src="photo_800.jpg"
srcset="photo_800.jpg 800w, photo_600.jpg 600w, photo_400.jpg 400w"
sizes="(min-width: 800px) 800px, 100vw"
width="800" height="600"
alt="Photo" loading="lazy" decoding="async" />
You rarely need to spell the widths and sizes out — a layout
knob derives both from one declaration:
echo $image->cover(800, 600)->img(alt: 'Photo', layout: 'constrained');
Width range
widths() takes any list, so a linear range is just range():
$image->widths(...range(400, 1200, 200)); // 400, 600, 800, 1000, 1200
Prefer a layout for real responsive delivery — a linear scale
over-provisions the small end (file size grows roughly with the square of width),
and on local providers every candidate is a real encoded, cached variant.
Density-descriptor srcset with densities()
For DPR-aware delivery without media queries, use densities():
$el = $factory->image('photo.jpg')->width(400)->densities(1, 2)
->img(alt: 'Photo');
echo $el;
// <img srcset="photo.jpg?w=800 2x, photo.jpg?w=400 1x" width="400" alt="Photo" … />
The browser picks the asset from devicePixelRatio alone — no sizes attribute
needed. layout: 'fixed' builds this set for you from densities(1, 2).
Use densities() when the display size is fixed and you only care about pixel
density; use widths() (or a constrained / full-width layout) when the box
scales with the viewport.
Format fallback
Enter markup with img(), then fan out to a <picture> with formats():
$picture = $image->cover(800, 600)
->img(alt: 'Photo')
->formats(Format::Webp);
echo $picture;
<picture>
<source srcset="photo_800.webp" type="image/webp" />
<img src="photo_800.jpg" alt="Photo" loading="lazy" decoding="async" />
</picture>
Combine with widths() for responsive + format — img() sits between the bytes
chain and the picture composition:
$image->cover(800, 600)->widths(800, 400)
->img(alt: 'Photo', sizes: '100vw')
->formats(Format::Avif, Format::Webp);
Art direction
Serve different crops per viewport. img() produces the fallback element; at()
adds a <source> for a media condition:
$mobile = $factory->image('hero.jpg', $meta)->cover(400, 400);
$desktop = $factory->image('hero.jpg', $meta)->cover(1200, 400);
$picture = $mobile->img(alt: 'Hero')
->at('(min-width: 768px)', $desktop)
->formats(Format::Webp);
echo $picture;
at() and formats() commute — declare them in any order, the markup is the same.
Breakpoint names and self-contained sources
at() accepts a screens name in place of a raw
query ('md' → (min-width: 768px), 'max-md' → (max-width: 767.98px)), and a
closure that receives the pristine root of the base image — the image as the
factory produced it, with none of the fallback’s manipulations — so an
art-directed source never accidentally inherits the fallback’s crop:
$factory->image('hero.jpg', $meta)
->cover(400, 400) // mobile fallback
->img(alt: 'Hero', sizes: ['md' => '50vw'])
->at('md', fn (Image $img) => $img->cover(960, 640)->widths(960, 1920))
->at('lg', fn (Image $img) => $img->cover(1440, 600)->widths(1440, 2880))
->formats(Format::Webp);
When every source uses a named min-width breakpoint, sources are sorted
min-width descending (the ordering hand-written markup gets wrong). Any raw or
max-* media present preserves insertion order.
Declarative (for Twig)
picture() is the declarative twin of the fluent chain — the same intent, with
sources as a media-keyed op map. alt comes first, then the sources and formats:
$picture = $image->picture(
alt: 'Hero',
sources: [
'lg' => ['cover' => [1440, 600], 'widths' => [1440, 2880], 'sizes' => '50vw'],
'md' => ['cover' => [960, 640]],
],
formats: ['webp'],
);
Each entry builds from the pristine root; the reserved sizes key is lifted out
before dispatch, and any other key is a manipulation (an unknown one throws). This
is the shape the Twig |picture filter uses.
Data URIs
Inline images as base64 (useful for small placeholders):
$dataUri = $image->crop(100, 100)->toDataUri();
// → data:image/jpeg;base64,...
For a placeholder shown while the real image loads, prefer the
placeholder argument to img() — it wraps a hash, color, or
tiny image as a CSS background and degrades gracefully.
HTML output
img() is the single entry to markup. The elements it produces —
ImgElement and PictureElement — implement MarkupInterface, so
casting to string emits HTML:
echo $image->img(alt: 'Photo', attrs: ['class' => 'hero']);
echo $set->img(alt: 'Photo', sizes: '100vw');
echo $picture;
Image::render() / Srcset::render() remain as a zero-config shortcut (they build
a default element with no alt). Stringifying an Image or a set still returns the
URL / candidate list — the descriptor types stringify to URL grammar, the element
types to HTML.
To customize the markup — lazy load, web components, <figure> wrappers, JSON
output — supply your own renderer to ImageFactory; see Rendering.