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
HashInputNormalizerexactly 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.