Deferred generation
Local providers (InterventionProvider, ImagineProvider) generate a variant inline, during the request that asks for its URL. Casting an Image to a string calls $provider->url($image), and on a cache miss that decodes, manipulates, encodes and writes the file before returning. A page with twenty cold variants pays twenty decode/encode passes in one PHP request, which is where timeouts, memory spikes and OOM come from.
This recipe moves the first generation out of the page request into a dedicated endpoint, the way Glide serves images: the page only emits URLs (cheap), and each variant is generated in its own isolated request when the browser fetches it.
The library does not impose this. It ships small, agnostic primitives; you choose the model and wire the HTTP. The eager url() / dataUri() behaviour is unchanged.
Why a token is needed
The local cache path is a one-way hash (HashedPathStrategy, xxh128). An endpoint that receives /cache/ab/cd/photo-<hash>.webp cannot recover the recipe (cover 800x600, blur 5, webp q80 of photo.jpg) from it. So the recipe has to travel to the endpoint some other way. ImageTokenSerializer is that channel: it serialises an image’s recipe (source + manipulators + encoding) into a compact, signed, URL-safe token, and back.
The primitives
ImageTokenSerializer::serialize(Image): string/deserialize(string): Image— the recipe channel.- On local providers (
LocalProviderInterface):checkSupport(Image): void— the same preflighturl()runs, exposed so an unexpressible chain (an unsupported manipulator, variant, or source format) fails at render time instead of surfacing as a broken image (or a 500) when the browser later fetches the endpoint URL.resolveFormat(Image): Format— runFormat::Autonegotiation once.has(Image, Format): bool— is the variant already cached (no generation).publicUrl(Image, Format): string— the deterministic cache URL (no generation).encode(Image, Format): EncodedImage— the bytes, generated on a cache miss (single-flight, likeurl()).
ImageFactory::withProvider(ProviderInterface): self— the same factory bound to a different provider, sharing every other collaborator.ImageFactory::imageFrom(source, manipulators, encoding): Image— assemble an image from a decoded recipe.
Wiring
Two providers, one shared configuration. The endpoint factory carries the real local provider; the page factory is the same factory with a thin decorator swapped in via withProvider(), so both agree on metadata versioning, presets and defaults.
use Timber\Chainsaw\ImageFactory;
use Timber\Chainsaw\ImageTokenSerializer;
use Timber\Chainsaw\Signer\HmacUrlSigner;
// Give the token codec its OWN key. Do not reuse a key you also use to sign
// CDN URLs, and do not truncate it (see Security below).
$mac = new HmacUrlSigner($_ENV['CHAINSAW_TOKEN_KEY']);
$local = new InterventionProvider(/* … */);
$endpointFactory = new ImageFactory(
provider: $local,
metadataResolver: $metadataResolver,
presets: $presets,
);
$codec = new ImageTokenSerializer($mac, $endpointFactory);
// The page emits deferred URLs instead of generating.
$pageFactory = $endpointFactory->withProvider(
new DeferredProvider($local, $codec, endpointBase: '/_img'),
);
The page-side decorator emits an endpoint URL carrying the token, and never generates. dataUri() still delegates to the real provider, because a LQIP is tiny by construction and there is nothing to defer:
use Timber\Chainsaw\Image;
use Timber\Chainsaw\Provider\InlineProviderInterface;
use Timber\Chainsaw\Provider\LocalProviderInterface;
use Timber\Chainsaw\Provider\ProviderInterface;
final class DeferredProvider implements ProviderInterface, InlineProviderInterface
{
public function __construct(
private LocalProviderInterface $local,
private ImageTokenSerializer $codec,
private string $endpointBase,
) {
}
public function url(Image $image): string
{
// Supportability is static registry knowledge — only generation is
// deferred. Run the same preflight url() runs, so an unexpressible
// chain fails here at render time instead of 500-ing when the
// browser fetches the endpoint URL.
$this->local->checkSupport($image);
// Resolve the format once, at render, and pin it into the recipe so
// the token carries it: the endpoint must generate the variant the
// page decided on, not re-negotiate against the image request's
// Accept header (see the Format::Auto caveat below).
$format = $this->local->resolveFormat($image);
$image = $image->format($format);
// Serve a warm variant straight from the cache path; defer a cold one.
return $this->local->has($image, $format)
? $this->local->publicUrl($image, $format)
: rtrim($this->endpointBase, '/') . '/' . $this->codec->serialize($image);
}
public function dataUri(Image $image): string
{
return $this->local->dataUri($image);
}
}
The endpoint decodes the token, generates, and serves. Here it 302s to the stable cache URL so the browser lands on the zero-PHP warm path; stream the bytes instead if you prefer one round trip (see the recipes below). Any framework works — this is plain pseudocode:
use Timber\Chainsaw\Exception\InvalidToken;
use Timber\Chainsaw\Exception\UnsupportedOperation;
function handleImageRequest(string $token, Request $request): Response
{
global $codec, $local; // the endpoint-side $codec and $local from above
try {
$image = $codec->deserialize($token);
} catch (InvalidToken) {
return new Response(400, 'Bad image token');
}
try {
// The token carries a pinned concrete format (see url() above), so
// this resolves without re-negotiating. Under recipe A with an
// unpinned token, it negotiates against THIS request's Accept instead.
$format = $local->resolveFormat($image);
$local->encode($image, $format);
} catch (UnsupportedOperation $e) {
// url() preflights with checkSupport(), so a refusal landing here
// means a forged or stale token (a manipulator/format the registry
// no longer supports) rather than a normal request — answer with the
// reason instead of a 500.
return new Response(422, $e->getMessage());
}
return new Response(302, headers: ['Location' => $local->publicUrl($image, $format)]);
}
Choose an isolation model
| Cold request | Warm request | Needs | |
|---|---|---|---|
A — always /_img/{token} |
endpoint generates + streams | endpoint reads + streams (cheap PHP) | one route |
| C — dual URL (shown above) | /_img/{token} → generate → 302 to /cache |
/cache/hash served statically, zero PHP |
one route + a has() check at render |
B — stable URL + try_files |
web server falls to @gen, regenerates |
/cache/hash served statically, zero PHP |
a hash → token store + web-server config |
- A is the most portable (no web-server cooperation, no store). Warm hits still enter PHP, so put a CDN or long
Cache-Controlin front. Stream the bytes from the endpoint instead of 302-ing. - C is the recommended default: warm hits are served statically with no store and no
try_files, at the cost of one cheaphas()per image at render. The 302 on the cold path lands the browser on the stable/cacheURL, so it never re-fetches. - B is the most CDN-friendly (one stable URL from the first paint). The page emits
publicUrl()always and writeshash → tokento a store; atry_files $uri @genfallback regenerates on a miss by reading the token back by hash.
Whatever the model, generation runs in the image request, not the page request. Put the endpoint behind its own PHP pool with its own memory_limit / max_execution_time if you want to isolate heavy transforms further; a variant that blows memory then fails its own request, not the page.
Caveats
Read these before shipping.
Format::Auto must be pinned at render for B and C. At render time the ambient request is the HTML document, whose Accept is text/html, not image/avif. resolveFormat() therefore negotiates the fallback (JPEG) at the page, while the endpoint later negotiates AVIF from the image request’s Accept. The two disagree on the resolved format — different format, different cache path — so has() probes a variant the endpoint never writes and the warm optimisation never fires. That is why url() above resolves once and pins the format into the image: the token must carry the format the page decided, so the endpoint generates that exact variant instead of re-negotiating. (The variant hash itself is indifferent to the pin — Encoding::$format is not a hash input, only the resolved format is — so the probe and the public URL agree with the eager provider’s paths either way.) Recipe A tolerates the drift (it streams whatever it generates) but must emit Vary: Accept if a CDN caches /_img/{token}.
Versioned sources. If you use a MetadataResolver that versions sources (mtime/etag), the page and the endpoint must resolve the version identically. withProvider() guarantees it by sharing the resolver, provided you build the endpoint factory and the page factory from the same base. The Format::Auto strategy lives on the provider, so both providers must be configured with equivalent strategies.
Security. The token is authenticated, not sanitised.
- Sign with a keyed MAC (
HmacUrlSigner). The codec refuses the unkeyedHashUrlSignerby type. Give the token codec its own secret, do not share the key with CDN URL signing, and do not truncate the signature. - A signed token can name any absolute-URL source or watermark, and the endpoint will fetch it. If any recipe you sign can be influenced by user input, the
SourceReaderInterfacebehind the endpoint must apply egress controls (block private and link-local ranges, or an allow-list), or the token becomes a signed SSRF request. - Deserialisation fails closed: a tampered, forged, oversized or structurally invalid token throws
InvalidToken, and no class outside the first-party allow-list is ever instantiated.
Token length. A typical token is a few hundred bytes. A very long manipulator chain, or a long remote source URL, produces a longer token; if it approaches your URL length limit, use recipe B (the browser-visible URL is the short hash, the token lives in the store).
Custom manipulators and source kinds
The token allow-list is closed by default, not final: it is extended with register(). Shipping your own manipulator (or a custom SourceRef kind) takes two registrations, both on the endpoint side.
First, tag the class with a vendor-prefixed identity token, like any hashed value object:
use Timber\Chainsaw\Attribute\HashIdentity;
use Timber\Chainsaw\Manipulator\ManipulatorInterface;
#[HashIdentity('acme:duotone')]
final readonly class Duotone implements ManipulatorInterface
{
public function __construct(
public string $shadow,
public string $highlight,
) {
}
}
Then register it in two places — the denormalizer allow-list so the endpoint can rebuild it from a token, and the local provider’s handler registry so it can actually be generated. The first is new here; the second is the existing handler seam:
$registry = HashIdentityRegistry::firstParty();
$registry->register(Duotone::class);
$codec = new ImageTokenSerializer($mac, $endpointFactory, new HashInputDenormalizer($registry));
$local->register(Duotone::class, $duotoneHandler); // generate it
The page side needs nothing extra: serialize() reads the #[HashIdentity] attribute by reflection, so it round-trips any tagged class. Only the endpoint (which deserializes) needs the class in the allow-list — that is the point of the closed set: a token naming an unregistered class fails closed with InvalidToken and is never instantiated.
One shape constraint: a custom kind must be reconstructible through its constructor, exactly like the first-party ones — every public property maps to a constructor parameter of the same name, and no property is a plain array (an emptied array cannot be told from a list on the way back). tests/Architecture/HashRoundTripGuardTest.php enforces this for first-party classes; for a userland kind it is a documented contract, and a violation surfaces as InvalidToken at decode, not a crash.
Placeholders
While a deferred variant loads, show a placeholder that costs nothing per request. Prefer a stored BlurHash or ThumbHash string over an inline dataUri(): the string is computed once and rendered with no per-request image work, whereas dataUri() decodes a small raster on every render.
Background workers
The same token also feeds a queue. Push it to a worker (Symfony Messenger, a WordPress Action Scheduler job, and so on) that calls encode() off the web tier entirely. This trades the deferred-but-synchronous image request for full asynchrony, at the cost of a placeholder until the worker catches up. See Long-Running Runtimes for warmup and pool sizing.