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.
Source types
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 into new WatermarkFromSource($source) for you.
  • A WatermarkSource value 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.1 decimals (percent), integer x_10 (pixels).
  • Thumbor / Imagor — native Xp notation (10p = 10% of image width/height), integer for pixels.
  • imgproxy — relative offsets (< 1.0) for percent; absolute (>= 1.0) for pixels.
  • ImgixpaddingUnit is pixel-only and symmetric (paddingX === paddingY); emits mark-pad. Imgix mark-x / mark-y are absolute pixel offsets that override mark-align, so asymmetric padding throws UnsupportedManipulator. sizeUnit=Percent is converted to pixels using upstream output dims (set via Width / Crop / Contain etc.) — throws if no upstream dim is resolvable. Native mark-w=0..1 is 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 throws UnsupportedManipulator (their tile primitive has no spacing knob).
  • imgproxy, ImageKit, Cloudflare, Wserv — no tile primitive; tiledWatermark() throws UnsupportedManipulator.
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');