Watermark
Composite a watermark image over the source.
watermark(WatermarkSource|string $source, Position $position = Position::BottomRight, float $paddingX = 0.0, float $paddingY = 0.0, Unit $paddingUnit = Unit::Pixel, float $width = 0.0, float $height = 0.0, Unit $sizeUnit = Unit::Pixel, WatermarkFit $fit = WatermarkFit::Contain, int $alpha = 100)
Widely available
Unsupported on Bunny, Cloudflare and Wserv.
BUCFCNGUIMIMIMIXIPIVTHWS
- All providers
- Unit and positioning semantics vary per provider — see watermark sub-matrix for source-type and unit detail.
WatermarkFromSource
BUCFCNGUIMIMIMIXIPIVTHWS
9/12
- Imgproxy
- Pro mode only (free tier uses `IMGPROXY_WATERMARK_URL` env var).
WatermarkFromServerDefault
BUCFCNGUIMIMIMIXIPIVTHWS
2/12
- Imgproxy
- Free tier: only `key === 'default'` (matches `IMGPROXY_WATERMARK_URL`); custom keys require Pro.
use Timber\Chainsaw\Enum\Position;
use Timber\Chainsaw\Enum\Unit;
$image->watermark(
'logo.png',
Position::BottomRight,
paddingX: 10,
paddingY: 10,
width: 25.0,
sizeUnit: Unit::Percent,
);
The first argument can be:
- A plain string or a
SourceRef— sugared intonew WatermarkFromSource($source)for you. - A
WatermarkSourcevalue object — explicit, required for server-side defaults.
Which source types each provider accepts, and how the ref is resolved to bytes or a fetch URL, is covered under Source resolution below.
Units
Two Unit parameters govern interpretation of the four numeric inputs:
| Unit | Applies to | Meaning |
|---|---|---|
$paddingUnit (default Unit::Pixel) |
$paddingX, $paddingY |
Pixel = absolute offset; Percent = fraction of base image width/height |
$sizeUnit (default Unit::Pixel) |
$width, $height |
Pixel = absolute target size; Percent = fraction of base image dims |
Both units are applied independently — you can mix them (e.g. percent padding with pixel size, or vice versa).
Provider mapping at URL-build time:
- Cloudinary — native
x_0.1decimals (percent), integerx_10(pixels). - Thumbor / Imagor — native
Xpnotation (10p= 10% of image width/height), integer for pixels. - imgproxy — relative offsets (
< 1.0) for percent; absolute (>= 1.0) for pixels. - Imgix —
paddingUnitis pixel-only and symmetric (paddingX === paddingY); emitsmark-pad. Imgixmark-x/mark-yare absolute pixel offsets that overridemark-align, so asymmetric padding throwsUnsupportedManipulator.sizeUnit=Percentis converted to pixels using upstream output dims (set viaWidth/Crop/Containetc.) — throws if no upstream dim is resolvable. Nativemark-w=0..1is a fraction of the watermark source, so direct emission would diverge from other providers. - Intervention — percent computed locally from base image dims.
See the provider support matrix for the per-cell story.
Proportional logo placement (percent padding + percent size)
Position a logo 5% from the bottom-right corner, sized to 20% of the base width — scales naturally with the output image:
$image->watermark(
'logo.png',
Position::BottomRight,
paddingX: 5.0,
paddingY: 5.0,
paddingUnit: Unit::Percent,
width: 20.0,
sizeUnit: Unit::Percent,
);
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
$source |
WatermarkSource | SourceRef | string |
required | Plain string or SourceRef sugars to WatermarkFromSource; otherwise pass the explicit VO |
$position |
Position |
BottomRight |
Placement (9-point grid) |
$paddingX |
float |
0.0 |
Horizontal offset — interpretation depends on $paddingUnit |
$paddingY |
float |
0.0 |
Vertical offset — same |
$paddingUnit |
Unit |
Pixel |
Unit applied to $paddingX and $paddingY |
$width |
float |
0.0 |
Target width (0 = original) — interpretation depends on $sizeUnit |
$height |
float |
0.0 |
Target height (0 = original) — same |
$sizeUnit |
Unit |
Pixel |
Unit applied to $width and $height |
$fit |
WatermarkFit |
Contain |
Scaling strategy |
$alpha |
int |
100 |
Opacity (0 = transparent, 100 = opaque) |
Positions
TopLeft, Top, TopRight, Left, Center, Right, BottomLeft, Bottom, BottomRight
Fit modes
| Mode | Behavior |
|---|---|
Contain |
Scale to fit inside the target box |
Fill |
Fill the box (may crop) |
Stretch |
Stretch to exact dimensions |
Crop |
Crop to fit the box |
Repeating (tiled) watermark
tiledWatermark() repeats the watermark across the whole output as a grid, with an optional gap between tiles. Per-tile size, fit, and opacity reuse the watermark() vocabulary; position does not apply — the grid spans the canvas.
use Timber\Chainsaw\Enum\Unit;
// A logo repeated every 120px tile + 40px gap, at 30% opacity.
$image->tiledWatermark(
'logo.png',
width: 120,
alpha: 30,
gapX: 40,
gapY: 40,
);
Tiling support is narrower than the single overlay:
- Intervention, Imagine (local) — full support, including the inter-tile gap.
- Cloudinary (
fl_tiled), Imgix (mark-tile), Thumbor / Imagor (repeat) — plain edge-to-edge tiling only. A non-zero gap throwsUnsupportedManipulator(their tile primitive has no spacing knob). - imgproxy, ImageKit, Cloudflare, Wserv — no tile primitive;
tiledWatermark()throwsUnsupportedManipulator.
| Parameter | Type | Default | Description |
|---|---|---|---|
$source |
WatermarkSource | SourceRef | string |
required | Same as watermark() |
$width / $height |
float |
0.0 |
Per-tile size (0 = the watermark’s natural size) |
$sizeUnit |
Unit |
Pixel |
Unit for $width / $height |
$fit |
WatermarkFit |
Contain |
Per-tile scaling strategy |
$alpha |
int |
100 |
Opacity (0 = transparent, 100 = opaque) |
$gapX / $gapY |
float |
0.0 |
Gap between tiles (local providers only) |
$gapUnit |
Unit |
Pixel |
Unit for the gap; Percent is a fraction of the base dims |
Factory defaults
Register a watermark as a factory default so all images get it:
use Timber\Chainsaw\Enum\Position;
use Timber\Chainsaw\Enum\Unit;
use Timber\Chainsaw\ImageFactory;
use Timber\Chainsaw\Manipulator\Watermark;
use Timber\Chainsaw\Watermark\WatermarkFromSource;
$factory = new ImageFactory(
provider: $provider,
defaults: [
new Watermark(
source: new WatermarkFromSource('logo.png'),
position: Position::BottomRight,
paddingX: 10,
paddingY: 10,
width: 20.0,
sizeUnit: Unit::Percent,
),
],
);
Skip the watermark for specific images:
$image->without(Watermark::class);
Cache freshness
On the local providers (InterventionProvider, ImagineProvider) the composited result is stored as a cached variant. Replacing a watermark file in place does not invalidate those variants by default — the watermark source enters the variant hash as address identity only, with no content version, so the old composite keeps being served. Either rename the file on each revision (the write-once default), pin a version: on the WatermarkFromSource, or wire an automatic versioner on the provider:
use Timber\Chainsaw\Watermark\WatermarkFromSource;
// Pin a version so an in-place overwrite moves the cache path.
$image->watermark(new WatermarkFromSource('logo.png', version: $deployRevision));
See the freshness ladder for the four options and their costs. URL-grammar providers have no variant cache, so this only concerns the local providers.
Source resolution
The plumbing behind the first argument: which source types each provider accepts, and how a ref becomes bytes or a fetch URL at build/render time.
Source types
A watermark source is either an addressable image (URL or storage path) or a reference to something the provider already knows about.
| Source | Class | Used for |
|---|---|---|
| Addressable image | WatermarkFromSource('https://…/logo.png') |
Most providers: Cloudinary, Imgix, Imagor, Thumbor, Intervention, imgproxy Pro |
| Server-side default | WatermarkFromServerDefault('key') |
imgproxy free (key === 'default' only, matches IMGPROXY_WATERMARK_URL); imgproxy Pro; Cloudinary (named public ID) |
Providers without a matching source type throw UnsupportedManipulator with an actionable reason (e.g. “Imgix has no server-default registry; use WatermarkFromSource”).
use Timber\Chainsaw\Watermark\WatermarkFromServerDefault;
$image->watermark(
new WatermarkFromServerDefault(), // uses the server's configured default
Position::BottomRight,
width: 20.0,
sizeUnit: Unit::Percent,
);
How each provider family resolves a WatermarkFromSource
The WatermarkFromSource wrapper holds any SourceRef — a plain URL string (UrlSource), a storage-relative path (PathSource), or a userland kind like MediaSource. What happens at URL-build or render time depends on the provider family.
Cloudinary, Imgproxy, Imagor, Thumbor — the source ref is resolved through locateWatermark(), which uses the same sourceLocator you wire for the main image source. A UrlSource passes through verbatim; everything else resolves through the locator. Without a locator, a PathSource throws UnsupportedManipulator with an actionable message (“A storage-relative watermark source requires a sourceLocator on this provider”), and any other kind throws UnresolvableSource.
Imgix, ImageKit — these two emit the ref raw against their own origin rather than fetching it themselves. A UrlSource emits its URL; a PathSource emits the storage path. Your Imgix or ImageKit account must already have that origin configured. Foreign kinds (e.g. MediaSource) throw UnsupportedManipulator because neither service can map an opaque ID to a fetch URL.
Intervention, Imagine (local providers) — watermark bytes are read through the provider’s budget-guarded read() method on AbstractLocalProvider. The same maxSourcePixels ceiling that protects the main source applies to the watermark too. If the watermark dimensions are not measurable and a budget is set, the request fails closed (BudgetExceeded).
use Timber\Chainsaw\Watermark\WatermarkFromSource;
// URL — works on URL-grammar providers without a locator. On local providers
// the default FlysystemSourceAdapter cannot read URLs; wire a URL-capable
// SourceReaderInterface to use a remote watermark there.
$image->watermark(new WatermarkFromSource('https://cdn.example.com/logo.png'));
// Storage path — Cloudinary/Imgproxy/Imagor/Thumbor need a sourceLocator;
// Imgix/ImageKit resolve it against their own configured origin
$image->watermark(new WatermarkFromSource('logos/logo.png'));
// Plain string is sugared to WatermarkFromSource automatically
$image->watermark('https://cdn.example.com/logo.png');