InterventionProvider

Processes images locally using Intervention Image v4 with GD, Imagick, or libvips (via intervention/image-driver-vips). Writes processed files to a cache filesystem.

use Timber\Chainsaw\Provider\Intervention\InterventionProvider;
use Timber\Chainsaw\Source\FlysystemSourceAdapter;

$provider = new InterventionProvider(
    sourceReader: new FlysystemSourceAdapter($sourceFilesystem),
    cache: $cacheFilesystem,
    cachePublicUrl: '/images/cache',
    manager: $imageManager,
);

The sourceReader parameter takes any Timber\Chainsaw\Source\SourceReaderInterfaceFlysystemSourceAdapter wraps a Flysystem filesystem and is the default. Custom implementations (e.g. signed S3 streams, in-memory test fixtures) just need to implement read(string): string.

Source pixel budget (maxSourcePixels)

Both local providers (InterventionProvider, ImagineProvider) accept an optional maxSourcePixels — a ceiling on a source image’s decoded pixel count (width × height), enforced before the image is decoded:

$provider = new InterventionProvider(
    sourceReader: new FlysystemSourceAdapter($sourceFilesystem),
    cache: $cacheFilesystem,
    cachePublicUrl: '/images/cache',
    manager: $imageManager,
    maxSourcePixels: 50_000_000, // reject sources over ~50 MP
);

This guards against decompression bombs: a tiny file whose header declares enormous dimensions (a ~142 KB image can claim 15000×150000 ≈ 2.25 Gpx, which would allocate ~9 GB once decoded). Memory blow-up is driven by decoded pixel count, not file weight, so the guard measures dimensions — from the image’s resolved metadata when present, otherwise a header-only probe — and throws Timber\Chainsaw\Exception\BudgetExceeded before the raster is allocated.

  • Default null — no limit.
  • Reject-only. An over-budget source throws; it is never silently downscaled. (Safely shrinking an oversized source requires driver-specific shrink-on-load decoding, which is not yet implemented.)
  • If a source’s dimensions can’t be determined and a budget is set, the guard fails closed (throws) rather than risk an unbounded decode.

maxSourcePixels caps the source, not the output. To bound output dimensions, apply a manipulator such as ->contain($w, $h, noUpscale: true) — a provider-level guard is the wrong tool for that (it would run after the source is already decoded).

Existence cache (existsPool)

Every url() call checks whether the variant already exists in the cache filesystem before deciding to (re)generate it. That check climbs a three-rung ladder: an in-process array (per request, always on), an optional PSR-6 pool, and finally the cache filesystem itself.

existsPool is that middle rung, and it exists for one deployment shape: a variant cache on remote storage (S3, NFS, …), where the filesystem rung is a network round-trip. A PSR-6 hit is ~0.7µs; an S3 HEAD is tens of milliseconds — a page emitting 100 variant URLs drops from seconds of existence checks to under a millisecond.

use Symfony\Component\Cache\Adapter\PhpFilesAdapter;

$provider = new InterventionProvider(
    sourceReader: new FlysystemSourceAdapter($sourceFilesystem),
    cache: $s3CacheFilesystem,                      // remote — the case existsPool is for
    cachePublicUrl: 'https://cdn.example.com/img',
    manager: $imageManager,
    existsPool: new PhpFilesAdapter('chainsaw'),    // requires OPcache; Redis/Memcached/APCu work too
    existsCacheTtl: 3600,
);

Do not wire it for a local-disk cache. A local fileExists() is a ~2µs stat; the pool saves ~1.4µs per URL with OPcache enabled and makes url() ~3× slower without it (PhpFilesAdapter degrades to an uncached include per lookup). The default existsPool: null is the right setting for local caches — numbers in bench/ExistenceCheckBench.php.

existsCacheTtl (default 3600s) is the staleness window: the pool cannot observe deletions it didn’t perform, so after an out-of-band deletion (an ops rm, an S3 lifecycle rule) a pool entry may keep vouching for a dead file — and url() will emit links to it — for up to TTL seconds. Library-driven deletion is exempt: purge() evicts pool entries in lockstep with the files.

An infinite TTL is only safe if purge() is the only thing that ever deletes cached variants. The moment anything else touches the cache storage, the TTL is your self-healing bound — keep it short.

Manipulation support

Manipulation Supported Availability
Width Free
Height Free
Scale Free
Cover Free; compass + focal anchors (detection anchors throw) — crop() is an alias
ManualCrop Free
Contain Free
Pad Free
Stretch Free
CropToRatio Free
PadToRatio Free
Blur Free
Sharpen Free
Brightness Free
Contrast Free
Gamma Free
Pixelate Free
Greyscale Free
Sepia Free
Background Free
Border Free
Flip Free
Rotate Free; arbitrary angles supported with optional bg fill (defaults to white)
AutoOrient Free; calls Intervention’s ->orient() (EXIF auto-orient is opt-in on Intervention)
Watermark Free
BlurHash Free
ThumbHash Free
Dither Free
Trim Free
Negate Free
Saturation Free (Imagick: modulateImage HSL; GD: pixel-wise HSL, slow; Vips: Rec. 709 luminance recomb matrix, vector op)
Hue Free (Imagick: modulateImage HSL; GD: pixel-wise HSL, slow; Vips: HSV colourspace round-trip, vector op)

Encoding

Format Supported
JPEG
PNG
WebP
AVIF ✅ (capability-probed, lazy)
GIF
Auto format ✅ (needs an injected AutoFormatStrategy)
Quality

Imagick modulateImage calibration

HueHandler and SaturationHandler map their canonical ranges to ImageMagick’s modulateImage percentage parameters with a linear formula:

  • HuehuePct = 100 + deg * 100 / 180 (100 = 0°, 200 = +180°, 0 = −180°).
  • SaturationsatPct = 100 + amount (0 = greyscale, 100 = unchanged, 200 = double).

These mappings are verified empirically against ImageMagick 7.x. Measured rotation on pure-red input matches the requested angle within ±0.2° across the 0°–330° grid; saturation scales proportionally with the percent parameter. The test suite locks this behavior — see tests/Provider/Intervention/Handler/HueHandlerIntegrationTest.php and SaturationHandlerIntegrationTest.php.

If you ship an ImageMagick build where modulateImage is known to be non-linear (some older 6.x builds were reported to be), please open an issue with a reproduction: the regression tests will catch the drift.

Vips Saturation/Hue: not pixel-identical to Imagick/GD

libvips has no HSL colourspace, so the Vips path uses driver-native vector ops instead of HSL math. Output is visually equivalent but not pixel-identical to the Imagick/GD columns.

  • Saturation — Rec. 709 luminance recomb matrix M = (1-s)·L + s·I, applied via recomb(). On the rgb(200,100,100) baseline, amount=-100 yields (121,121,121) (luminance grey) where Imagick/GD yield (150,150,150) (HSL midpoint). Difference of ~10–15 channels on saturated inputs; visually both read as “fully desaturated”.
  • Huecolourspace('hsv') round-trip with the H band shifted by angle * 256/360 and wrapped modulo 256. libvips encodes H as uchar (256 buckets across 360°), so output diverges from the HSL grid by up to ~4 channels at boundary angles (180°, 270°). Desaturated input also picks up ~3–4 channels of round-trip noise on S/V even at 0° — there’s no closed-form identity through the HSV uchar conversion.

If you need cross-driver pixel parity for these two manipulators, pin intervention-imagick or intervention-gd. Otherwise the Vips path runs as a single vector op instead of a per-pixel PHP loop, which is the throughput Vips users expect.

The Vips behavior is locked in saturationGridProviderVips and hueGridProviderVips data sets in the integration tests.

Placeholder generators: BlurHash vs ThumbHash

Both ->blurHash(width, height, componentsX?, componentsY?) and ->thumbHash(width, height) produce small decoded preview images suitable for inlining via ->toDataUri().

  • ThumbHash preserves alpha and generally produces a more faithful preview at the same byte budget.
  • BlurHash is more widely adopted across client libraries.

From Chainsaw’s side the ergonomics are identical:

$inline = $factory->image('photo.jpg')->thumbHash(width: 32, height: 32)->toDataUri();
echo '<img src="' . $factory->image('photo.jpg') . '" style="background-image: url(' . $inline . ')">';

ThumbHash does not expose basis-function component counts — the algorithm selects them from aspect ratio.

Notes

  • Most complete provider – supports every manipulator in the library (all 31), including BlurHash, ThumbHash and Dither, which are local-only (here and on ImagineProvider).
  • Format capability (WebP, AVIF) is probed lazily and memoized on first use — never in the constructor (GD via function_exists, Imagick/Vips via a one-off 1×1 trial encode, since Imagick::queryFormats() is unreliable). An explicit request for a format the backend can’t encode throws UnsupportedOperation.
  • Implements InlineProvider for base64 data URIs via $image->toDataUri().
  • Three-layer existence cache: in-memory, optional PSR-6 pool, filesystem — the pool is for remote cache storage only, see Existence cache.
  • Format::Auto is resolved by an AutoFormatStrategyInterface you inject — the library ships no Accept-header parser, and Auto throws without a strategy. See Advanced > Format negotiation.