Advanced
Device pixel ratio
Multiply dimensions for hi-DPI displays by composing dpr() with any resize manipulator:
$image->width(400)->dpr(2); // 800px output
For browser-side density selection across multiple resolutions, build a srcset with width descriptors via widths() and let the browser pick — no Client Hints headers required, works on every backend.
Format negotiation
Format::Auto defers format selection until URL build time. How it’s resolved depends on the provider:
| Provider | Mode | Behavior |
|---|---|---|
InterventionProvider, ImagineProvider |
Origin-side | Delegate to an AutoFormatStrategyInterface you inject; throw if none is set. |
CloudflareProvider |
CDN-side | Emits format=auto; Cloudflare negotiates per request. |
CloudinaryProvider |
CDN-side | Emits f_auto; Cloudinary negotiates per request. |
ImagekitProvider |
CDN-side | Emits f-auto; ImageKit negotiates per request. |
ImgixProvider |
CDN-side | Emits auto=format; imgix negotiates per request. |
The remaining URL-grammar providers (imgproxy, Imagor, Thumbor, wsrv) ignore Format::Auto — set format() explicitly, or compose a <picture> with multiple <source type="..."> elements for client-side selection.
Origin-side (local providers)
The library ships no Accept-header parser: there is no PSR for content negotiation, and a general-purpose library can’t assume how you model the request (PSR-7, $_SERVER, a framework Request…), so negotiation is the app’s job. Implement AutoFormatStrategyInterface and inject it — without one, the local providers (InterventionProvider, ImagineProvider) throw on Format::Auto.
use Timber\Chainsaw\Enum\Format;
use Timber\Chainsaw\Format\AutoFormatStrategyInterface;
use Timber\Chainsaw\Format\FormatSupportInterface;
use Timber\Chainsaw\Image;
final class AcceptAutoFormatStrategy implements AutoFormatStrategyInterface
{
public function resolve(Image $image, FormatSupportInterface $support): Format
{
$accept = $_SERVER['HTTP_ACCEPT'] ?? '';
// Most modern format the client accepts AND the backend can encode.
return match (true) {
$support->supports(Format::Avif) && str_contains($accept, 'image/avif') => Format::Avif,
$support->supports(Format::Webp) && str_contains($accept, 'image/webp') => Format::Webp,
default => Format::Jpg,
};
}
}
$provider = new InterventionProvider(
// ... source, cache, cachePublicUrl, manager
autoFormat: new AcceptAutoFormatStrategy(),
);
The $support argument (FormatSupportInterface) is the backend’s encode-capability detector, supplied by the provider. Consult it so you never negotiate a format the GD/Imagick/Vips build can’t actually produce. The provider probes capability lazily by default (EncoderCapabilities on Intervention, ImagineFormatSupport on Imagine); pass your own formatSupport: to override or to skip probing. Whatever the strategy returns is guarded anyway — an unencodable format throws UnsupportedOperation rather than failing deep in the encoder.
ImagineProvider accepts the same autoFormat: constructor argument — one strategy implementation serves both local providers.
A PSR-7 / Symfony app reads the header from its request object instead of $_SERVER — the contract is identical:
$accept = $this->request->getHeaderLine('Accept'); // PSR-7 ServerRequestInterface
Caching caveat. Origin-side Auto bakes the chosen format into a static URL at build time. If a full-page cache serves that HTML to every client, the first visitor’s format reaches browsers that can’t decode it. That’s the deployer’s call: fine when HTML renders per request, but under full-page caching prefer client-side selection — a <picture> with typed <source> elements (->picture()->formats(...)) so the browser picks from cache-safe markup. The resolved format is part of the variant cache path, so AVIF and JPEG variants never collide on disk.
CDN-side (Cloudflare, Cloudinary, ImageKit, imgix)
$image->format(Format::Auto); // emits format=auto / f_auto / f-auto / auto=format
The URL is identical for every visitor; the CDN performs the negotiation per request based on the Accept header it sees.
URL signing
Cloudinary URLs can be signed to prevent tampering:
use Timber\Chainsaw\Signer\HmacUrlSigner;
use Timber\Chainsaw\Provider\Cloudinary\CloudinaryProvider;
$provider = new CloudinaryProvider(
cloudName: 'my-cloud',
apiSecret: 'my-secret',
signer: new HmacUrlSigner(hex2bin('signing-key')),
);
Available signers
| Signer | Algorithm | Use case |
|---|---|---|
HmacUrlSigner |
HMAC-SHA256, base64url, 8-char prefix | Cloudinary compatibility |
HashUrlSigner |
Configurable hash, no key | Simple hash-based signing |
Batch operations
Apply manipulations from an array (useful with Twig or config-driven pipelines):
use Timber\Chainsaw\ManipulationDispatcher;
$result = ManipulationDispatcher::apply($image, [
'crop' => [800, 600],
'greyscale' => [],
'quality' => [80],
]);
Method names map to Image methods. Width-transitioning methods (widths, widthsBetween) must come last and change the return type to WidthSet.
Removing manipulators
Remove a specific manipulator from the chain:
use Timber\Chainsaw\Manipulator\Watermark;
$image->without(Watermark::class);
This is useful when factory defaults add manipulators you want to skip for specific images.
Automatic cache invalidation
Chainsaw caches at three layers:
| Layer | What it caches | Keyed by (default) |
|---|---|---|
| L2 — metadata | Source dimensions, populated via CachedMetadataResolver |
source path |
| L3 — variants | Processed image bytes, local providers (InterventionProvider, ImagineProvider) only |
source path + format + encoding + manipulators |
| L4 — CDN edge | URL-provider output | the emitted URL |
By default, all three are immortal — overwriting a source file in place keeps serving stale metadata, stale variants, and stale CDN-cached bytes. You can purge manually (see Cache Purging), or opt into automatic invalidation by plugging a Source\SourceVersionerInterface implementation.
How it works
SourceVersionerInterface exposes one method:
public function version(string $source): ?string;
The returned string changes whenever the source bytes change. Mtime is the canonical default; content hash, etag, and DB revision identifiers all work equally.
When the MetadataResolverInterface wrapped by CachedMetadataResolver implements SourceVersionerInterface, the library automatically:
- Folds the version into the L2 PSR-6 cache key
- Folds the version into the L3 variant filename hash
- Emits a version marker in every L4 output URL (
?v={version}on most providers;/v{version}/path segment on Cloudinary)
Both shipped resolvers already implement SourceVersionerInterface using mtime:
use Timber\Chainsaw\Metadata\CachedMetadataResolver;
use Timber\Chainsaw\Source\FlysystemSourceAdapter;
$factory = new ImageFactory(
provider: $provider,
metadataResolver: new CachedMetadataResolver(
upstream: new FlysystemSourceAdapter($source), // implements SourceVersionerInterface via lastModified()
pool: $psr6Pool,
),
);
// Overwrite photo.jpg on disk → next $factory->image('photo.jpg') detects
// the new mtime → L2/L3/L4 all regenerate automatically.
Use a custom version strategy by wiring your own MetadataResolverInterface that implements SourceVersionerInterface:
use Timber\Chainsaw\Metadata\MetadataResolverInterface;
use Timber\Chainsaw\Source\SourceVersionerInterface;
use Timber\Chainsaw\Metadata\ImageMetadata;
final readonly class ContentHashResolver implements MetadataResolverInterface, SourceVersionerInterface
{
public function resolve(string $source): ?ImageMetadata { /* ... */ }
public function version(string $source): ?string
{
// Or pull a revision number from your DB, an etag from S3 HEAD, etc.
return hash_file('xxh128', '/srv/images/' . $source) ?: null;
}
}
Cost per call
version() fires on every resolve() call. On local filesystems it’s a stat() — negligible. On remote Flysystem adapters (S3, GCS) it’s a HEAD request — measurable. If your CDN sits in front of a Flysystem-backed origin, prefer explicit Purgeable::purge($src) on content change (cheaper per read) to mtime-probing.
CDN caveats
- Cloudflare CDN caches respect query strings under the default
Standardcache level. If your zone is configured to “Ignore Query String” (Cache Rules or legacy Page Rules),?v=is silently stripped — you’ll need a Cache Rule including thevparam, or a path-based version scheme. - Cloudinary uses its native
/v{version}/path segment — signature-exempt by design, survives their own edge and any downstream CDN. - Imgproxy / Imagor / Thumbor receive the version on the source URL they fetch from origin (so their internal cache key differs per version). The origin is expected to ignore the
?v=query — standard nginxtry_filesdoes.
Orphaned entries
The L2 PSR-6 pool and L3 variant filesystem accumulate entries per version (they’re keyed by current version, and old versions become unreachable but aren’t garbage-collected automatically). Either:
- Periodically flush the PSR-6 pool and L3 cache directory
- Configure a TTL on your PSR-6 pool
- Call
$factory->purger()?->purge($src)after known-safe overwrites