Automatic 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(SourceRef $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 wired metadata resolver can version its sources — it implements SourceVersionerInterface itself, or it is a CachedMetadataResolver wrapping (or given) one — the library automatically:
- 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) - Folds the version into the L2 PSR-6 cache key (
CachedMetadataResolverderives its versioner from the upstream; passversioner:to override)
This works with or without the cache decorator — a bare FlysystemSourceAdapter as metadataResolver: already versions its own resolves.
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\Metadata\ImageMetadata;
use Timber\Chainsaw\Source\PathSource;
use Timber\Chainsaw\Source\SourceRef;
use Timber\Chainsaw\Source\SourceVersionerInterface;
final readonly class ContentHashResolver implements MetadataResolverInterface, SourceVersionerInterface
{
public function resolve(SourceRef $source): ?ImageMetadata { /* ... */ }
public function version(SourceRef $source): ?string
{
// Or pull a revision number from your DB, an etag from S3 HEAD, etc.
return $source instanceof PathSource
? (hash_file('xxh128', '/srv/images/' . $source->storagePath()) ?: null)
: null; // null = no version info — immortal-cache behaviour for this kind
}
}
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.
Watermark sources
Everything above versions the main image source. A watermark is a second source, composited in by the local providers (InterventionProvider, ImagineProvider), and it has its own freshness story — because its cost profile is different. A watermark version is new I/O on a path that was otherwise pure, multiplied by the srcset fan-out (widths(8) = eight url() calls = eight lookups for the same logo), so watermark versioning is opt-in, not on by default.
By default a WatermarkFromSource enters the variant hash as address identity only — its SourceRef, with no content version. Overwrite logos/logo.png in place and every cached watermarked variant keeps serving the old composite. Four rungs, cheapest first:
1. Write-once (default). Give a new revision a new name (logo-v2.png) — the address changes, the hash changes, variants regenerate structurally. Nothing to configure. Media-library integrations (WordPress attachments, Laravel Media Library) already work this way, and it is the right default for the roughly nine in ten pipelines that never overwrite in place.
2. Manual version:. When a watermark is overwritten in place — a deploy asset, a CMS field — pass the version yourself. Zero I/O, exact semantics, and the natural model for a logo whose real “version” is a deploy revision or an asset-manifest entry:
use Timber\Chainsaw\Watermark\WatermarkFromSource;
$image->watermark(new WatermarkFromSource('logo.png', version: $deployRevision));
A version set here always wins over an automatic resolver (below): the caller-pinned value is authoritative.
3. Automatic sourceVersioner:. Wire a SourceVersionerInterface on the local provider and it stamps every watermark source for you. The shipped FlysystemSourceAdapter implements it via lastModified() (mtime), so the reader you already wire can double as the versioner:
use Timber\Chainsaw\Provider\Intervention\InterventionProvider;
use Timber\Chainsaw\Source\FlysystemSourceAdapter;
$adapter = new FlysystemSourceAdapter($watermarkStorage);
$provider = new InterventionProvider(
sourceReader: $adapter,
// ... cache, cachePublicUrl, manager
sourceVersioner: $adapter, // mtime via lastModified()
);
// Overwrite logos/logo.png → its mtime changes → the next url() stamps the new
// version → every watermarked variant regenerates automatically.
Cost: one stat (local disk, negligible) or one HEAD (S3/GCS, measurable) per watermark per url(). A versioner answering null for a source (foreign kind, transient failure) leaves it unversioned for that call — the same immortal-cache behaviour as wiring no versioner. Versioned entries stay coherent, but freshness is not guaranteed through a null window: if an unversioned variant of the chain already exists, it keeps being served until a version resolves again.
4. Remote storage — CachedSourceVersioner. On remote storage the per-url() HEAD adds up (20 images × widths(8) = 160 HEADs for one logo). Wrap the versioner in a PSR-6 TTL memo — the staleness window is explicit (“changes visible within at most N seconds”):
use Timber\Chainsaw\Source\CachedSourceVersioner;
$provider = new InterventionProvider(
sourceReader: $adapter,
// ... cache, cachePublicUrl, manager
sourceVersioner: new CachedSourceVersioner($adapter, $psr6Pool, ttl: 60),
);
The TTL is required, not defaulted: here the memo entry is the freshness, so an immortal one would pin the first answer forever (unlike CachedMetadataResolver, whose entries are keyed by version and stay correct when immortal). The same decorator also fits CachedMetadataResolver’s versioner: slot, bounding the main-source HEADs the same way.
URL-grammar providers (Cloudflare, Cloudinary, …) have no variant cache and are out of scope here — their edge refetches the located watermark URL, so watermark freshness there is the CDN’s own ?v= concern.
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
Library upgrades never bust your cache
Cache identity is Image state plus source version() — the library version is deliberately not part of any key. Upgrading Chainsaw regenerates nothing: L2, L3, and L4 keep serving the bytes they already hold.
This is on purpose. A release can change a handler’s output (a dithering tweak, a sharpen recalibration), but the library can’t know whether your images route through the changed code, and most releases (docs, an unrelated provider, a typo fix) change no bytes at all. Folding the version into the hash would force a global regeneration — potentially tens of GB — on every patch. That is an operational decision, so it stays yours to make consciously, keyed off the release notes, never something an upgrade does behind your back.
When a release note says output changed and you want already-cached variants to adopt it, bust explicitly. Two levers:
Salt the local variant path (global, local providers only). Bump the string and every L3 path shifts:
use Timber\Chainsaw\Naming\HashedPathStrategy;
$provider = new InterventionProvider(
// ... source, cache, cachePublicUrl, manager
pathStrategy: new HashedPathStrategy(salt: 'chainsaw-v3'),
);
Fold a suffix into the source version (coupled local + CDN). A SourceVersionerInterface decorator appends any axis — library version, deploy SHA, tenant — onto the inner version, so L2/L3 and the L4 ?v= marker all move together:
use Timber\Chainsaw\Metadata\ImageMetadata;
use Timber\Chainsaw\Metadata\MetadataResolverInterface;
use Timber\Chainsaw\Source\SourceRef;
use Timber\Chainsaw\Source\SourceVersionerInterface;
final readonly class SuffixedSourceVersion implements MetadataResolverInterface, SourceVersionerInterface
{
public function __construct(
private MetadataResolverInterface&SourceVersionerInterface $inner,
private ?string $suffix, // e.g. 'chainsaw-v3', a deploy SHA; null in dev
) {
}
public function resolve(SourceRef $source): ?ImageMetadata
{
return $this->inner->resolve($source);
}
public function version(SourceRef $source): ?string
{
$inner = $this->inner->version($source);
if ($this->suffix === null) {
return $inner;
}
return $inner === null ? $this->suffix : $inner . '-' . $this->suffix;
}
}
$factory = new ImageFactory(
provider: $provider,
metadataResolver: new CachedMetadataResolver(
upstream: new SuffixedSourceVersion(
inner: new FlysystemSourceAdapter($source),
suffix: 'chainsaw-v3',
),
pool: $psr6Pool,
),
);
Chainsaw ships the SourceVersionerInterface seam, not this decorator as a concrete class, on purpose. The composition is application policy: the joiner (- above), the direction (inner version first, the suffix last), and which source kinds even receive the suffix (all of them here — you might exempt remote UrlSources, or suffix only one tenant’s paths) all vary per deployment. A shipped helper would freeze one set of those choices; the twenty lines above keep them yours.
Either way the pre-bump entries orphan rather than delete (see Orphaned entries above) — flush the L2 pool and L3 directory, or purge(), once the new variants are warm.
Extracting a source palette
A metadata decorator can add facts, not only a version. PaletteMetadataResolver is a shipped, opt-in decorator that extracts a small colour palette from the source once and stamps it onto ImageMetadata->palette — a population-ordered list of RGB triplets, the dominant colour first (->palette[0]). Nothing extracts a palette unless you wire it; the default resolvers leave ->palette null and read no extra bytes.
Wrap it inside CachedMetadataResolver so the palette is cached beside the dimensions and re-extracted only when the source version changes — the decorator forwards version() to its inner, so the automatic invalidation above keeps working:
use Timber\Chainsaw\Metadata\CachedMetadataResolver;
use Timber\Chainsaw\Metadata\PaletteMetadataResolver;
use Timber\Chainsaw\Raster\Quantize\MedianCutCodec;
use Timber\Chainsaw\Source\FlysystemSourceAdapter;
$adapter = new FlysystemSourceAdapter($filesystem); // resolver + reader + versioner in one
$metadataResolver = new CachedMetadataResolver(
upstream: new PaletteMetadataResolver(
inner: $adapter, // intrinsic dimensions + version
reader: $adapter, // reads the source bytes to quantise
codec: new MedianCutCodec(),
maxSourcePixels: 24_000_000, // skip an oversized source before decoding
maxSourceBytes: 20_000_000, // skip a heavy file before reading
),
pool: $psr6Pool,
);
$factory = new ImageFactory(provider: $provider, metadataResolver: $metadataResolver);
$palette = $metadataResolver->resolve($source)?->palette;
// [[34, 40, 51], [201, 180, 160], …] ordered by area, the dominant colour first
The two budgets bound the extra decode the extraction costs, and both are skips, not errors — an over-budget source simply goes without a palette. maxSourceBytes refuses a file heavier than the cap before it is read; maxSourcePixels refuses one whose declared area is larger than the cap. With maxSourcePixels set, a source whose dimensions are unknown is skipped too (fail-closed — its size cannot be verified); pass maxSourcePixels: null to quantise unmeasured sources anyway.
Extraction reads the source bytes and decodes them locally, so it suits local providers and any reader that can fetch the bytes. A remote UrlSource the reader cannot read fails soft to a null palette — pre-populate the palette from the CDN (or your own pipeline) for those.