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\SourceReaderInterface — FlysystemSourceAdapter 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. Passnullto 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 highermaxSourcePixels.
maxSourcePixelscaps only the source raster. To reject a request whose output would blow up — a small source amplified by a resize or DPR — usemaxOutputPixelsbelow. 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). Passnullto 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 rasterregardless 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 likemaxSourcePixels— 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 passnullto disable. - Best-effort. When the reader can’t report a size cheaply (
fileSize()returnsnull— 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 tinyviewBoxcan carry a huge document). It is enforced on the read bytes once the source is sniffed as SVG. Passnullto 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 aVariantCache(or aSingleFlight, below) yourself, pass your PSR-3 logger to it too — otherwise its cache-tier, purge, and lock logs go to aNullLogger.
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:
- Hue —
huePct = 100 + deg * 100 / 180(100 = 0°, 200 = +180°, 0 = −180°). - Saturation —
satPct = 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 viarecomb(). 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”. - Hue —
colourspace('hsv')round-trip with the H band shifted byangle * 256/360and 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, sinceImagick::queryFormats()is unreliable). An explicit request for a format the backend can’t encode throwsUnsupportedOperation. - Implements
InlineProviderfor 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::Autois resolved by anAutoFormatStrategyInterfaceyou inject — the library ships noAccept-header parser, andAutothrows without a strategy. See Format Negotiation.