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]);
});

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): string is typed, testable, and has one slot. Events have N slots 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/PostProcess do not fire on cache hits — they live inside process(), 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::$encoded carries the full raw image; holding the event keeps those bytes in memory.