Custom Cache Naming
Local providers (InterventionProvider, ImagineProvider) address each cached variant through a Naming\PathStrategyInterface. The default is HashedPathStrategy, which lays a variant out as:
<source-shard>/<source-dir>/<source-stem>-<hash>.<ext>
The <source-shard> is a hash of the source identity — it groups every variant of one source under a single directory, which is what lets purge() sweep them in one delete. The library owns that shard and the <hash>; both are frozen cache identity. What you can customize is the readable filename beneath the shard.
Tweak the filename — inject a NamingStrategyInterface
NamingStrategyInterface owns the in-shard filename and nothing else:
namespace Timber\Chainsaw\Naming;
interface NamingStrategyInterface
{
/** The in-shard relative path for a variant (may contain a '/'). */
public function name(NamingContext $context): string;
}
name() receives a NamingContext — the pre-computed identity hash plus the raw material to arrange a readable name:
| Field | Type | What it is |
|---|---|---|
$hash |
string |
The opaque 16-hex variant identity. Uniqueness lives here — embed it (see below). |
$dirname |
string |
The source’s directory segment, traversal-stripped (may be ''). |
$filename |
string |
The source’s stem, traversal-stripped (may be ''). |
$format |
Format |
Resolved output format; the extension is $format->value. |
$manipulators |
list<ManipulatorInterface> |
The requested manipulation chain, for serializing into the name. |
$encoding |
Encoding |
Encoding intent (quality, …), for the same. |
Wire your strategy as the second constructor argument of HashedPathStrategy — the salt keeps its first-argument position:
use Timber\Chainsaw\Naming\HashedPathStrategy;
$provider = new InterventionProvider(
// ... source, cache, cachePublicUrl, manager
pathStrategy: new HashedPathStrategy(
salt: 'chainsaw-v3',
naming: new ReadableNamingStrategy(),
),
);
The shard (grouping) and the hash stay library-owned, so this tier cannot break purge() or forge identity. The library re-strips ./.. from your output, so a returned ../escape can’t climb out of the shard, and a name that strips to nothing is refused with an InvalidArgumentException (it would otherwise compose <shard>/ and corrupt the cache). The worst a buggy name() can still do is collide two variants of the same source — bounded, never cross-source — which is exactly why the hash must always be embedded (below).
Always embed $hash
The readable part is decoration — SEO, debuggability. It is not a uniqueness mechanism, and the only sanctioned rule is: always embed $context->hash. The hash already folds in the source, its version, the resolved format, quality, every manipulator, and the salt — none of which a readable projection captures in full. Salt in particular is unreachable from NamingContext by design (it is sealed inside the hasher), so a hash-free name can never notice a salt bump and would serve stale bytes forever — the exact silent staleness the cache model exists to prevent.
A readable-only name collides the moment two variants differ in something the readable part omits:
$img->cover(300, 400)->greyscale()->quality(80); // fichier-300-400-greyscale.jpg
$img->cover(300, 400)->greyscale()->quality(60); // same name, different bytes — collision
Embedding the hash makes the name unique whatever the decoration leaves out. The library ships no manipulator-to-string serializer — formatting the chain is the strategy’s own job, and it may show as much or as little as it likes:
use Timber\Chainsaw\Manipulator\Cover;
use Timber\Chainsaw\Manipulator\Greyscale;
use Timber\Chainsaw\Naming\NamingContext;
use Timber\Chainsaw\Naming\NamingStrategyInterface;
final readonly class ReadableNamingStrategy implements NamingStrategyInterface
{
public function name(NamingContext $context): string
{
$parts = [$context->filename];
foreach ($context->manipulators as $manipulator) {
$parts[] = match (true) {
$manipulator instanceof Cover => $manipulator->width . '-' . $manipulator->height,
$manipulator instanceof Greyscale => 'greyscale',
default => null, // omit whatever you don't want to spell out
};
}
// The hash is uniqueness; the readable parts above are pure decoration.
$parts[] = $context->hash;
$stem = implode('-', array_filter(
$parts,
static fn (?string $part): bool => $part !== null && $part !== '',
));
$dir = $context->dirname !== '' ? $context->dirname . '/' : '';
return $dir . $stem . '.' . $context->format->value;
}
}
For $factory->image('fichier.jpg')->cover(300, 400)->greyscale() this yields:
fichier-300-400-greyscale-<hash>.jpg
A pure-readable name with no hash is deliberately not offered — it cannot be made salt-coherent through this seam.
A whole different scheme — implement the strategy directly
Injecting a NamingStrategyInterface only rearranges the filename under the library’s shard. To own the entire path, including how variants are grouped, implement one of two interfaces directly and compose a VariantHasher for the frozen identity hash.
GroupedPathStrategyInterface is the purgeable contract — it adds directory(), the per-source subtree purge() deletes:
namespace Timber\Chainsaw\Naming;
interface GroupedPathStrategyInterface extends PathStrategyInterface
{
public function path(Image $image, Format $format): string; // full variant path
public function directory(SourceRef $source): string; // per-source purge subtree
}
Two contracts you now own — the library cannot enforce either, so this is the deliberate, visible step where the danger-zone lives:
path()must sit underdirectory().purge($source)deletesdirectory($source); ifpath()writes a variant outside it, purge silently misses it.directory()must be injective — distinct sources yield distinct, non-nested directories. Two sources sharing a directory meanspurge()on one deletes the other’s variants too; a directory nested inside another’s has the same effect. The default shard is injective by construction (it hashes the full source identity), so reusingVariantHasher::shardOf()fordirectory()inherits that safety.
Compose the sealed hasher so identity stays frozen — a swap in and back out of the default naming is byte-neutral:
use Timber\Chainsaw\Naming\VariantHasher;
$hasher = new VariantHasher($salt);
$hash = $hasher->hash($image, $format); // 16-hex variant identity
$shard = $hasher->shardOf($image->source); // injective per-source directory
A flat scheme — deliberately not purgeable
If you don’t need purge(), implement the bare PathStrategyInterface (just path()) and skip grouping entirely. This is honest rather than broken: a provider whose strategy does not group is treated exactly like a URL/CDN provider that can’t purge —
$factory->purger()returnsnull, so a fleet-wide$registry->purger()->purge(...)silently skips it.- A targeted
$registry->purger('name')throwsFactoryNotPurgeable.
Purgeability is therefore a runtime property of the strategy you wire, decided by whether it implements GroupedPathStrategyInterface. The default HashedPathStrategy does, so purge works out of the box.