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 Standard cache 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 the v param, 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 nginx try_files does.

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