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 under directory(). purge($source) deletes directory($source); if path() 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 means purge() 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 reusing VariantHasher::shardOf() for directory() 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() returns null, so a fleet-wide $registry->purger()->purge(...) silently skips it.
  • A targeted $registry->purger('name') throws FactoryNotPurgeable.

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.