Rendering
Chainsaw separates what an image is (data: Image, the Srcset sets WidthSet
/ DensitySet) from presentation policy (resolved into an
ImgElement / PictureElement by img()) from how the element
becomes HTML (renderers). Policy lives in the element; renderers are pure emitters.
The defaults emit standard, semantic markup; swap any of three renderer contracts to
produce lazy-loaded <img>, web components, JSON for headless APIs, or anything else.
The three contracts
Each maps 1:1 to one HTML element and receives a fully-resolved value object:
namespace Timber\Chainsaw\Render;
interface ImgRendererInterface
{
public function render(ImgElement $img, array $attrs = []): MarkupInterface;
}
interface SourceRendererInterface
{
public function render(SourceElement $source, array $attrs = []): MarkupInterface;
}
interface PictureRendererInterface
{
public function render(PictureElement $picture, array $attrs = []): MarkupInterface;
}
MarkupInterface extends \Stringable is a marker interface — output is already-safe
HTML, distinct from URL/srcset text. The default value object is Render\Markup.
The element types (ImgElement, PictureElement) are themselves MarkupInterface:
casting one to a string renders it through the carried renderer.
Defaults
The library ships Render\HtmlImgRenderer, Render\HtmlSourceRenderer, and
Render\HtmlPictureRenderer. They emit a clean, modern shape:
<img src="..." srcset="..." sizes="100vw" width="800" height="600"
alt="Field at dusk" loading="lazy" decoding="async" />
<source srcset="..." media="(min-width: 1024px)" type="image/webp" width="1600" height="900" />
<picture>
<source ... />
<img ... />
</picture>
width / height come from the dimension chain (MetadataResolverInterface if
configured, plus DimensionAware manipulators). The presentation attributes
(alt, loading, decoding, …) are resolved by img() and carried
on the element — renderers only emit what the element holds. A null attribute value
drops the attribute entirely. No alt is invented: the element carries whatever you
passed, and the zero-config shortcut carries none.
Getting an element to render
echo $image->img(alt: 'Field at dusk'); // <img>
echo $set->img(alt: 'Hero', sizes: '100vw'); // srcset <img>
echo $picture; // <picture>
echo $el->render(['class' => 'hero']); // last-minute attrs
Image::render() / Srcset::render() survive as a zero-config shortcut that builds
a default element internally (no alt). Image::__toString() and the sets’
__toString() still return the URL / srcset string (so <img src="{{ image }}">
works); the element types stringify to HTML.
In Twig, |img is the terminal that prints the tag:
{{ image|img(alt: 'Field at dusk') }}
{{ image|widths(400, 800)|img(alt: 'Hero', sizes: '100vw') }}
{{ image|picture(alt: 'Hero', formats: ['webp']) }}
Bring your own renderer
Inject a custom renderer at ImageFactory construction time. Three optional ctor
params:
$factory = new ImageFactory(
provider: $cdn,
imgRenderer: new LazysizesImgRenderer(), // optional
sourceRenderer: new LazysizesSourceRenderer(), // optional
pictureRenderer: new FigurePictureRenderer(), // optional
);
Anything you don’t supply uses the default. The defaults compose: HtmlPictureRenderer
is constructed with the resolved ImgRendererInterface and SourceRendererInterface,
so a custom ImgRendererInterface automatically applies to the inner <img> of every
<picture> — no decorator gymnastics required.
Worked example — lazy load via lazysizes
Goal: emit <img> with data-src (and data-srcset for srcset sets) so the
lazysizes JS library can swap on intersect.
The renderer receives a resolved ImgElement, so it reads accessors instead of
recomputing policy:
namespace App\Render;
use Timber\Chainsaw\Output\ImgElement;
use Timber\Chainsaw\Output\ImgRendererInterface;
use Timber\Chainsaw\Output\MarkupInterface;
use Timber\Chainsaw\Render\Markup;
final readonly class LazysizesImgRenderer implements ImgRendererInterface
{
public function __construct(
public string $placeholder = 'data:image/svg+xml,%3Csvg/%3E',
) {}
public function render(ImgElement $img, array $attrs = []): MarkupInterface
{
$parts = [
sprintf('src="%s"', htmlspecialchars($this->placeholder, ENT_QUOTES)),
sprintf('data-src="%s"', htmlspecialchars($img->src(), ENT_QUOTES)),
];
if (($srcset = $img->srcset()) !== null) {
$parts[] = sprintf('data-srcset="%s"', htmlspecialchars($srcset, ENT_QUOTES));
}
if (($width = $img->width()) !== null) {
$parts[] = sprintf('width="%d"', $width);
}
if (($height = $img->height()) !== null) {
$parts[] = sprintf('height="%d"', $height);
}
$parts[] = 'class="lazyload"';
$parts[] = sprintf('alt="%s"', htmlspecialchars($img->alt ?? '', ENT_QUOTES));
return new Markup('<img ' . implode(' ', $parts) . ' />');
}
}
Wire it once:
$factory = new ImageFactory(
provider: $cdn,
imgRenderer: new LazysizesImgRenderer(),
);
Now every img() / render() and the inner <img> of every <picture> emits the
lazy markup. <picture> and <source> use the defaults.
For a lazy-load convention that stays on the default renderer, use the
element’s null-drop: attrs: ['srcset' => null, 'data-srcset' => $set] moves the candidate list to a data attribute without a
custom renderer.
Customizing only <source>
Implement SourceRendererInterface and pass to the factory. The default
HtmlPictureRenderer calls your renderer for each <source>; the default <img>
and <picture> skeleton are reused.
Customizing only <picture>
Implement PictureRendererInterface (e.g. to wrap the output in
<figure><figcaption>). You can compose the existing img/source defaults — your
ctor accepts an injected ImgRendererInterface and SourceRendererInterface. The
fallback is an ImgElement, ready to hand straight to the img renderer:
final readonly class FigurePictureRenderer implements PictureRendererInterface
{
public function __construct(
private ImgRendererInterface $img = new HtmlImgRenderer(),
private SourceRendererInterface $source = new HtmlSourceRenderer(),
) {}
public function render(PictureElement $picture, array $attrs = []): MarkupInterface
{
$sources = array_map(
fn (SourceElement $s): string => (string) $this->source->render($s, $attrs),
$picture->sources,
);
$img = (string) $this->img->render($picture->fallback, $attrs);
$caption = isset($attrs['caption'])
? sprintf('<figcaption>%s</figcaption>', htmlspecialchars((string) $attrs['caption'], ENT_QUOTES))
: '';
return new Markup(
'<figure><picture>' . implode('', $sources) . $img . '</picture>' . $caption . '</figure>',
);
}
}
$picture->sources is the declared source list. Media × format expansion (the
type-less group source, the fallback format pin) happens inside the default
HtmlPictureRenderer; a from-scratch PictureRendererInterface that bypasses it
is responsible for its own expansion.
Other shapes
The contract is open — emit anything that’s MarkupInterface-compatible:
- Web components:
<lazy-img src="...">,<amp-img>, etc. - JSON: return a JSON-encoded payload wrapped in
Markupfor a headless API
(or reach forImgElement::jsonSerialize()). - Stimulus / Alpine / Vue: emit
data-controller="image"etc. <noscript>fallback: wrap the lazy markup with a no-JS fallback inside.
Per-call renderer overrides
The factory wires one renderer family. For one-off customization, instantiate a
renderer and call it with an element:
echo (new LazysizesImgRenderer())->render($image->img(alt: 'Hero'));
The renderer doesn’t need anything from the factory — it operates on the element you
pass in.
Twig integration
The |img and |picture filters build and print elements through the
renderers carried on the Image (set by the factory). A custom renderer wired into
the factory automatically applies to every filter call in templates — no template
change needed. Both filters are marked is_safe: ['html'], so {{ image|img(alt: '…') }} prints raw without |raw; passing an element as a plain variable
({{ el }}) needs the one-line safe-class registration described in
Twig.