Rendering
Chainsaw separates what an image is (data: Image, the Srcset sets WidthSet / DensitySet, Picture) from how it becomes HTML (renderers). The defaults emit standard, semantic markup; you can 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:
namespace Timber\Chainsaw\Render;
interface ImgRendererInterface
{
public function render(Image|Srcset $image, array $attrs = []): MarkupInterface;
}
interface SourceRendererInterface
{
public function render(Source $source, array $attrs = []): MarkupInterface;
}
interface PictureRendererInterface
{
public function render(Picture $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\Html.
Defaults
The library ships Render\HtmlImgRenderer, Render\HtmlSourceRenderer, and Render\HtmlPictureRenderer. They emit a clean, modern shape:
<img src="..." srcset="..." width="800" height="600" alt="" />
<source srcset="..." media="(min-width: 1024px)" type="image/webp" width="1600" height="900" />
<picture>
<source ... />
<source ... />
<img ... />
</picture>
Default width/height come from the dimension chain (MetadataResolverInterface if configured, plus DimensionAware manipulators); alt="" is set to keep markup HTML-valid even when the user forgets to pass one.
Calling render()
echo $image->render(['alt' => 'Field at dusk']);
echo $set->render(['alt' => 'Hero', 'sizes' => '100vw']); // WidthSet / DensitySet
echo $picture->render(['alt' => 'Hero']);
Image::__toString() and the sets’ __toString() still return the URL / srcset string respectively (so <img src="{{ image }}"> works in templates). Picture is not Stringable — call ->render() explicitly.
In Twig, the |render filter is the terminal step:
{{ image|render({ alt: 'Field at dusk' }) }}
{{ image|widths(400, 800)|render({ sizes: '100vw' }) }}
{{ image|picture({}, ['webp'])|render({ alt: 'Hero' }) }}
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 FigureWrappedPictureRenderer(), // 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. Default <source> and <picture> markup, the inner <img> of every <picture> should also be lazy.
namespace App\Render;
use Timber\Chainsaw\Contract\Srcset;
use Timber\Chainsaw\Render\ImgRendererInterface;
use Timber\Chainsaw\Render\MarkupInterface;
use Timber\Chainsaw\Image;
use Timber\Chainsaw\Render\Html;
final readonly class LazysizesImgRenderer implements ImgRendererInterface
{
public function __construct(
public string $placeholder = 'data:image/svg+xml,%3Csvg/%3E',
) {}
public function render(Image|Srcset $image, array $attrs = []): MarkupInterface
{
$base = $image instanceof Srcset ? $image->fallback() : $image;
$dim = $base->dimensions();
$parts = [
sprintf('src="%s"', htmlspecialchars($this->placeholder, ENT_QUOTES)),
sprintf('data-src="%s"', htmlspecialchars((string) $base, ENT_QUOTES)),
];
if ($image instanceof Srcset) {
$parts[] = sprintf('data-srcset="%s"', htmlspecialchars((string) $image, ENT_QUOTES));
}
if ($dim->width !== null) {
$parts[] = sprintf('width="%d"', $dim->width);
}
if ($dim->height !== null) {
$parts[] = sprintf('height="%d"', $dim->height);
}
$parts[] = 'class="lazyload"';
$parts[] = sprintf('alt="%s"', htmlspecialchars((string) ($attrs['alt'] ?? ''), ENT_QUOTES));
return new Html('<img ' . implode(' ', $parts) . ' />');
}
}
Wire it once:
$factory = new ImageFactory(
provider: $cdn,
imgRenderer: new LazysizesImgRenderer(),
);
Now every Image::render(), WidthSet::render() / DensitySet::render(), and the inner <img> of every Picture::render() emits the lazy markup. <picture> and <source> use the defaults.
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:
final readonly class FigurePictureRenderer implements PictureRendererInterface
{
public function __construct(
private ImgRendererInterface $img = new HtmlImgRenderer(),
private SourceRendererInterface $source = new HtmlSourceRenderer(),
) {}
public function render(Picture $picture, array $attrs = []): MarkupInterface
{
$sources = array_map(
fn (Source $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 Html(
'<figure><picture>' . implode('', $sources) . $img . '</picture>' . $caption . '</figure>',
);
}
}
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
Htmlfor a headless API - 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 directly:
echo (new LazysizesImgRenderer())->render($image, ['alt' => 'Hero']);
The renderer doesn’t need access to anything from the factory — it operates on the value object you pass in.
Twig integration
The Twig |render filter dispatches through the renderers carried on the Image (set by the factory). Custom renderers wired into the factory automatically apply to every |render call in templates — no template change needed.
The filter is marked is_safe: ['html']. The method-call form {{ image.render() }} requires |raw because Twig auto-escapes method results regardless of return type; prefer the filter form.