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(SourceRef): string — throw UnreadableSource (wrapping the backend failure) when the bytes can’t be delivered, and UnresolvableSource for source kinds the adapter doesn’t know (see Custom source kinds).

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 ~100 MP (DEFAULT_MAX_SOURCE_PIXELS) — comfortably clears any phone / DSLR photo. Pass null to disable the guard for fully trusted pipelines.
  • 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.
  • Animated sources count every frame. An animated GIF/WebP decodes frame-by-frame, so its true cost is frames × canvas, not the logical-screen size a header probe reports. The guard multiplies the canvas by the frame count, so a legitimately large animation needs a proportionally higher maxSourcePixels.

maxSourcePixels caps only the source raster. To reject a request whose output would blow up — a small source amplified by a resize or DPR — use maxOutputPixels below. To instead fit output into a box without throwing, apply a manipulator such as ->contain($w, $h, noUpscale: true).

Output pixel budget (maxOutputPixels)

The source guard can’t catch an upscale bomb: a small, in-budget source that a Width, Cover, Scale, or DPR multiplier amplifies into an enormous raster. maxOutputPixels closes that gap — a ceiling on the predicted output pixel count, enforced before the source is decoded:

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

The output size is predicted from the source dimensions run through the (DPR-normalized) manipulator chain, so like maxSourcePixels it throws BudgetExceeded before any raster is allocated.

  • Default ~100 MP (DEFAULT_MAX_OUTPUT_PIXELS). Pass null to disable.
  • Best-effort. Only an unknown source size skips the check — there is nothing to bound the output from. A content-dependent manipulator (a Trim, whose crop extent isn’t known before decode) is bounded by its pre-op size rather than skipped, and every size-amplifying manipulator is predictable, so a bomb can’t hide behind that gap.
  • Animated sources count every frame on the output side too — the handlers iterate each frame, so the predicted figure is frames × output raster regardless of the final encode.

Under a long-running worker both budgets double as memory governors — the decoded raster is a worker’s peak allocation. See Long-Running Runtimes.

Source byte cap (maxSourceBytes, maxSvgBytes)

The pixel budgets measure decoded size, but the raw source is materialized into a PHP string first — a multi-GB file would exhaust memory at the read call, before any pixel count is known. maxSourceBytes guards that: a cheap fileSize() probe (no read) refuses a source larger than the cap before its bytes are pulled into memory.

$provider = new InterventionProvider(
    sourceReader: new FlysystemSourceAdapter($sourceFilesystem),
    cache: $cacheFilesystem,
    cachePublicUrl: '/images/cache',
    manager: $imageManager,
    maxSourceBytes: 200_000_000, // reject sources over ~200 MB before reading
);
  • Default ~100 MB (DEFAULT_MAX_SOURCE_BYTES), a coarse bomb backstop like maxSourcePixels — wide enough that anything the pixel budget normally admits clears it (a ≤ 100 MP JPEG/WebP/AVIF/JXL is under ~60 MB). A huge photographic PNG (a 90 MP scan) can exceed it; raise the knob for those pipelines, or pass null to disable.
  • Best-effort. When the reader can’t report a size cheaply (fileSize() returns null — a foreign source kind, a missing file, a backend that doesn’t expose sizes) the probe is skipped and the read proceeds. The probe never throws; a truly unreadable source surfaces at the read instead.
  • maxSvgBytes (default ~5 MB, DEFAULT_MAX_SVG_BYTES) is the tighter cap for a vector source, whose parse cost the pixel budget cannot measure (a tiny viewBox can carry a huge document). It is enforced on the read bytes once the source is sniffed as SVG. Pass null to disable.

Existence cache (VariantCache)

Variant storage — the existence checks below, crash-safe publication, and coherent purging — is owned by a Provider\VariantCache. Passing a bare Flysystem operator to cache: wraps it in one with defaults (no existence pool, staged writes); construct the VariantCache yourself to tune either.

The provider’s logger: only reaches collaborators the provider builds itself. When you construct a VariantCache (or a SingleFlight, below) yourself, pass your PSR-3 logger to it too — otherwise its cache-tier, purge, and lock logs go to a NullLogger.

Every url() call checks whether the variant already exists in the cache filesystem before deciding to (re)generate it. That check has two tiers: an optional PSR-6 pool, then 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;
use Timber\Chainsaw\Provider\VariantCache;

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

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.

Direct writes (supportsDirectWrites)

By default a variant is published crash-safe: written to a staging path, then moved into place, so a request dying mid-encode never leaves a truncated file where url() vouches for a complete one. On backends where every write is already all-or-nothing (S3 and most object stores), that staging hop is a wasted round-trip — declare it with supportsDirectWrites: true on the VariantCache and publish() writes the final path directly.

Single-flight (SingleFlight)

When several concurrent requests miss the same uncached variant they each generate it — correct (the writes are last-writer-wins and byte-identical) but wasteful on slow encodes. Pass an optional Provider\SingleFlight built on a symfony/lock LockFactory (with timeout / ttl, both whole seconds) to de-duplicate: the first request generates while the others wait for it and read its result.

use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Lock\Store\RedisStore;
use Timber\Chainsaw\Provider\SingleFlight;

$provider = new InterventionProvider(
    // ...
    singleFlight: new SingleFlight(new LockFactory(new RedisStore($redis)), timeout: 10, ttl: 300),
);

It is best-effort, never a correctness gate: a waiter that times out generates anyway, a lock-store outage degrades the same way (logged, never thrown), and locking never blocks url() indefinitely. symfony/lock is a suggest dependency; without a SingleFlight the behaviour is unchanged.

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.

Blur calibration

Blur(N) targets a gaussian σ ≈ N/2 px on every backend — the anchor shared by imgproxy, wsrv.nl and, after recalibration, imgix and Cloudflare (see the support-matrix precision notes):

  • Imagick — Intervention’s native blurImage(N, 0.5·N) already sits on the anchor; delegated untouched.
  • vips — Intervention’s native gaussblur(0.53·N) likewise; delegated untouched.
  • GD — GD’s only primitive is a fixed 3×3 kernel pass (σ ≈ 0.71·√passes, contrast collapse past ~200 passes), so the handler plans full-resolution passes for small sigmas and a downscale → blur → upscale cycle for large ones, switching to a premultiplied-alpha two-image pass when the image carries transparency (GD’s kernel is otherwise blind to alpha edges and darkens them). Every animation frame is blurred.

Accuracy is tolerance-band, not exact: the suite pins measured σ within [0.7, 1.4]× the target on GD, within ±20% on Imagick/vips, and GD↔Imagick within 1.5× of each other (tests/Provider/Intervention/Handler/BlurHandlerIntegrationTest.php). Same cross-backend contract as the sections below: equivalent output across backends, never byte-identical.

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.

Dither: cross-backend luma differs

Dithering first reduces the image to greyscale, and each backend’s greyscale is different: GD and vips use BT.601 luma (0.299·R + 0.587·G + 0.114·B), while Imagick greyscales via modulateImage(100, 0, 100) — HSL lightness (max + min) / 2. So the same source dithers to a different black-and-white pattern depending on the backend. All three are valid monochrome renderings; they read as visually comparable, not pixel-identical.

This is the library’s cross-backend contract in general: swapping GD ↔ Imagick ↔ vips gives an equivalent transformation, not byte-identical output. The library overrides a backend only when its result is wrong, not merely different — the vips dither path computes BT.601 luma in PHP precisely because vips’s native colourspace(B_W) linearises luminance and turned green-heavy images all-white. Imagick’s lightness greyscale is left as-is: a different but correct result.

If you need identical dither bytes across environments, pin a single backend (intervention-gd, intervention-imagick, or intervention-vips) so every request greyscales the same way.

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().
  • Two-tier existence cache: optional PSR-6 pool, then 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 Format Negotiation.