PSR-14 Events
Chainsaw fires four PSR-14 events from the provider hot path. They’re observer-only — listeners receive information about what the provider is doing, but they do not modify it.
For transformation use cases, use a dedicated typed contract instead of an event:
UrlRewriter— URL string rewriting (secondary-CDN prefix swap)
Events do not participate in caching.
The four events
| Event | Fires in | Carries (all readonly) |
|---|---|---|
PreUrlGenerated |
AbstractUrlProvider::url() — top |
Image, Provider |
PostUrlGenerated |
AbstractUrlProvider::url() — end, after UrlRewriter |
string $url, Image, Provider |
PreProcess |
AbstractLocalProvider::process() — top (Intervention, Imagine) |
Image, AbstractLocalProvider |
PostProcess |
AbstractLocalProvider::process() — end, after encoding |
EncodedImage $encoded, Image, AbstractLocalProvider |
Pre events expose the inputs to the operation; Post events expose the outputs. Listeners can read but not change anything.
Wiring a dispatcher
Dispatcher goes on the provider constructor, not on ImageFactory. Works with any PSR-14-compatible dispatcher (Symfony EventDispatcher, league/event, etc.). Chainsaw does not ship a dispatcher.
use Symfony\Component\EventDispatcher\EventDispatcher;
use Timber\Chainsaw\Provider\Cloudflare\CloudflareProvider;
use Timber\Chainsaw\Event\PostUrlGenerated;
$dispatcher = new EventDispatcher();
$dispatcher->addListener(PostUrlGenerated::class, function (PostUrlGenerated $event): void {
// observe only
});
$provider = new CloudflareProvider(
host: 'https://example.com',
dispatcher: $dispatcher,
);
Listener examples
Count generated URLs per provider
use Timber\Chainsaw\Event\PostUrlGenerated;
$dispatcher->addListener(PostUrlGenerated::class, function (PostUrlGenerated $event) use ($metrics): void {
$metrics->increment('chainsaw.url.generated', tags: [
'provider' => $event->provider::class,
]);
});
Record a variant-URL index for later purging
PostUrlGenerated fires once per URL generation. Persist the URL against its source so purgers can enumerate variants when the source changes. (See GitHub issue #25 for the full pattern.)
use Timber\Chainsaw\Event\PostUrlGenerated;
$dispatcher->addListener(PostUrlGenerated::class, function (PostUrlGenerated $event) use ($indexPool): void {
$item = $indexPool->getItem(hash('xxh128', $event->image->source));
$urls = $item->isHit() ? $item->get() : [];
$urls[$event->url] = true; // dedupe by key
$item->set($urls);
$item->expiresAfter(3600 * 24 * 30);
$indexPool->save($item);
});
Byte-size histogram for local-provider output
use Timber\Chainsaw\Event\PostProcess;
$dispatcher->addListener(PostProcess::class, function (PostProcess $event) use ($metrics): void {
$metrics->histogram(
'chainsaw.processed.bytes',
$event->encoded->size(),
tags: ['format' => $event->encoded->format->value],
);
});
Log cache-miss processing
PreProcess and PostProcess fire only when a local provider actually generates bytes (cache miss). Useful for counting how often the cache is missed.
use Timber\Chainsaw\Event\PreProcess;
$dispatcher->addListener(PreProcess::class, function (PreProcess $event) use ($logger): void {
$logger->info('chainsaw cache miss', ['source' => $event->image->source]);
});
Related typed contracts
For anything that actually changes the processing output, use the dedicated contract — not events.
Contract\UrlRewriter — URL post-processing
use Timber\Chainsaw\Provider\ProviderInterface;
use Timber\Chainsaw\Contract\UrlRewriter;
use Timber\Chainsaw\Image;
final readonly class SecondaryCdnRewriter implements UrlRewriter
{
public function rewrite(string $url, Image $image, Provider $provider): string
{
if (random_int(0, 99) < 10) {
return str_replace('cdn-a.example.com', 'cdn-b.example.com', $url);
}
return $url;
}
}
$provider = new CloudflareProvider(
host: 'https://cdn-a.example.com',
urlRewriter: new SecondaryCdnRewriter(),
);
No version() on UrlRewriter: URL string changes naturally produce new CDN edge cache keys, so downstream caching invalidates automatically.
Why events are observer-only
Events are PSR-14’s native pattern for “notify N listeners of a state change.” They’re the wrong shape for transformations:
- Cache coherence: mutating listeners would invisibly change processing output without participating in the variant cache hash, silently serving stale bytes.
- Ordering sensitivity: with multiple mutation-capable listeners, result depends on registration order — a silent correctness trap.
- Shape mismatch: a transformation is a function returning a value, not a notification.
UrlRewriter::rewrite(url, image, provider): stringis typed, testable, and has one slot. Events haveNslots with no return contract.
Use events for side-channel concerns (metrics, logging, indexing) where “N observers each doing their own thing” is genuinely what you want. Use typed contracts for anything that needs to participate in cache keys or affect output.
Gotchas
PreProcess/PostProcessdo not fire on cache hits — they live insideprocess(), which only runs on cache miss. Observer listeners counting “requests served” will undercount; observers counting “bytes generated” will count correctly.- Listener exceptions propagate. A throwing listener aborts
url()/dataUri(). Catch in your listener if you need it to be non-fatal. - Events are dispatched in registration order across listeners for the same event class. Your dispatcher controls that order.
- Do not retain event references in a long-lived context.
PostProcess::$encodedcarries the full raw image; holding the event keeps those bytes in memory.