Custom Source Kinds

Image::$source is a Source\SourceRef, not a string — PathSource and UrlSource ship, and the set is open. A kind that addresses sources by database ID (a CMS media library, a DAM) is a value object extending the base:

use Timber\Chainsaw\Attribute\HashIdentity;
use Timber\Chainsaw\Enum\Format;
use Timber\Chainsaw\Source\SourceRef;

#[HashIdentity('app:media')]
final readonly class MediaSource extends SourceRef
{
    public function __construct(
        public int $id,
        public ?string $variant = null,
    ) {
    }

    public function cachePathHint(): string
    {
        // Cosmetic cache-path fragment; uniqueness is owned by the variant hash.
        return 'media-' . $this->id;
    }

    public function formatHint(): ?Format
    {
        // An ID carries no extension — set ->format() or wire an AutoFormatStrategy.
        return null;
    }
}

Three rules make a kind cache-safe (the SourceRef docblock is the authoritative contract):

  • Public readonly props are the cache identity. They’re projected through HashInputNormalizer exactly like manipulators — add a prop and variants regenerate; private state is invisible to the hash.
  • Tag the class #[HashIdentity('vendor:kind')], vendor-prefixed (source: is first-party-reserved). Untagged kinds fall back to FQCN, so a class rename moves every cache path.
  • Sources are pure data. No repositories or filesystems as props — resolution belongs to the adapter below.

Providers never see a MediaSource directly — resolution happens at the seams, and one adapter serves both provider families:

use Timber\Chainsaw\Source\Exception\UnresolvableSource;
use Timber\Chainsaw\Source\SourceAdapterInterface;
use Timber\Chainsaw\Source\SourceRef;
use Timber\Chainsaw\Source\SourceVersionerInterface;
use Timber\Chainsaw\Source\UrlSource;

final readonly class MediaAdapter implements SourceAdapterInterface, SourceVersionerInterface
{
    public function __construct(
        private MediaRepository $media,
    ) {
    }

    // URL-grammar providers: ref → fetchable absolute URL
    public function locate(SourceRef $source): UrlSource
    {
        return match (true) {
            $source instanceof MediaSource => new UrlSource($this->media->find($source->id)->publicUrl($source->variant)),
            $source instanceof UrlSource => $source,
            default => throw new UnresolvableSource($source, self::class),
        };
    }

    // Local providers: ref → source bytes
    public function read(SourceRef $source): string
    {
        return match (true) {
            $source instanceof MediaSource => $this->media->find($source->id)->contents(),
            default => throw new UnresolvableSource($source, self::class),
        };
    }

    // Automatic cache invalidation — see Caching → Automatic Invalidation
    public function version(SourceRef $source): ?string
    {
        return $source instanceof MediaSource
            ? (string) $this->media->find($source->id)->updatedAt->getTimestamp()
            : null;
    }
}

Keep the default => throw arm — a kind the adapter doesn’t know must fail fast (UnresolvableSource names both the kind and the consumer), never produce a half-built URL. A URL provider given a MediaSource with no locator wired throws the same way.

Wired up, the media row supplies everything the library would otherwise probe from bytes:

$factory = new ImageFactory(
    provider: $provider,                 // MediaAdapter as sourceLocator (URL providers) or sourceReader (local)
    metadataResolver: new CachedMetadataResolver(
        upstream: $mediaMetadata,        // dims straight from the media table — bytes never probed
        pool: $pool,
        versioner: $adapter,             // updatedAt → L2/L3/L4 invalidate when the media changes
    ),
);

$factory->image(new MediaSource(42))->cover(1200, 630)->format(Format::Webp);

tests/Source/MediaSourceIntegrationTest.php keeps a working end-to-end copy of this pattern, including the version-flow proof.

->watermark() accepts any SourceRef too, so ->watermark(new MediaSource(7)) works through the same adapter — the locator resolves it to a fetchable URL on the locator-based URL providers (Cloudinary, Imgproxy, Imagor, Thumbor — Imgix and ImageKit never consult the locator for overlays), and read() returns the bytes on local providers. See the watermark page for the per-family rules.