ScreenshotNeo

BlogHTML to image & PDF

How to Add an Image Watermark to a PDF with PHP Guzzle

Use Guzzle to fetch files, FPDI to import pages, and FPDF or TCPDF to overlay a transparent image watermark in PHP.

By the ScreenshotNeo team29 September 20268 min read

How to Add an Image Watermark to a PDF with PHP Guzzle

Direct answer

Guzzle cannot place an image on a PDF page. It is the HTTP client in this workflow: use it to download or stream the source PDF and watermark image, then pass those files to a PDF library. FPDI imports pages from the existing document, while FPDF or TCPDF creates the output page and draws the watermark. This separation is the key to a reliable implementation. Guzzle documentation describes Guzzle as a PHP HTTP client for sending requests and integrating with web services. FPDI provides the page-import layer.

The example below downloads a PDF and PNG with Guzzle, imports every page with FPDI, applies a semi-transparent diagonal stamp, and writes watermarked.pdf. It also works when the PDF and image are already local files.

1. Install the libraries

Create a project and install Guzzle, FPDF, and FPDI. FPDI supports FPDF and TCPDF backends; choose the backend already used by your application.

Guzzle transfers the assets; FPDI imports pages; the PDF backend draws the watermark.
Guzzle transfers the assets; FPDI imports pages; the PDF backend draws the watermark.
composer require guzzlehttp/guzzle setasign/fpdf setasign/fpdi

For a TCPDF project, install tecnickcom/tcpdf and the FPDI TCPDF bridge documented by Setasign, then adapt the class names in the rendering section. Check PHP and package constraints against the versions installed in your project.

2. Download the inputs with Guzzle

Guzzle accepts response bodies as strings, resources, or PSR-7 streams. For large files, stream directly to disk so the entire PDF is not held in memory. Always check the HTTP status and content before handing a file to FPDI.

<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
$client = new Client([
    'timeout' => 60,
    'connect_timeout' => 15,
    'http_errors' => false,
]);
function download(Client $client, string $url, string $destination): void
{
    $response = $client->get($url, ['sink' => $destination]);
    $status = $response->getStatusCode();
    if ($status < 200 || $status >= 300) {
        @unlink($destination);
        throw new RuntimeException('Download failed with HTTP ' . $status . ': ' . $url);
    }
    if (!is_file($destination) || filesize($destination) === 0) {
        throw new RuntimeException('Empty download: ' . $url);
    }
}
try {
    download($client, 'https://example.com/source.pdf', __DIR__ . '/source.pdf');
    download($client, 'https://example.com/stamp.png', __DIR__ . '/stamp.png');
} catch (GuzzleException|RuntimeException $e) {
    fwrite(STDERR, $e->getMessage() . PHP_EOL);
    exit(1);
}

Use an allow-list or signed URLs when URLs come from users. Do not let an untrusted URL turn this endpoint into a server-side request forgery proxy. If authentication is required, pass headers or query parameters through Guzzle and keep secrets out of logs.

3. Import each page and overlay the watermark

FPDI’s documented sequence is to open the source, get its page count, import a page, create a matching output page, and place the imported template. The following script preserves each page’s size and orientation, then places the image at a configurable position.

Calculate placement from each page’s dimensions so portrait, landscape, and rotated pages align.
Calculate placement from each page’s dimensions so portrait, landscape, and rotated pages align.
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use setasign\Fpdi\Fpdi;
$source = __DIR__ . '/source.pdf';
$stamp  = __DIR__ . '/stamp.png';
$output = __DIR__ . '/watermarked.pdf';
if (!is_readable($source) || !is_readable($stamp)) {
    throw new RuntimeException('Source PDF or watermark image is not readable.');
}
$pdf = new Fpdi();
$pageCount = $pdf->setSourceFile($source);
for ($pageNo = 1; $pageNo <= $pageCount; $pageNo++) {
    $templateId = $pdf->importPage($pageNo);
    $size = $pdf->getTemplateSize($templateId);
    $pdf->AddPage($size['orientation'], [$size['width'], $size['height']]);
    $pdf->useTemplate($templateId);
    $stampWidth = min(55.0, $size['width'] * 0.30);
    $stampHeight = $stampWidth * 0.28;
    $x = ($size['width'] - $stampWidth) / 2;
    $y = ($size['height'] - $stampHeight) / 2;
    if (method_exists($pdf, 'SetAlpha')) {
        $pdf->SetAlpha(0.28);
    }
    $pdf->Image($stamp, $x, $y, $stampWidth, $stampHeight, 'PNG');
    if (method_exists($pdf, 'SetAlpha')) {
        $pdf->SetAlpha(1);
    }
}
$pdf->Output('F', $output);
echo 'Wrote ' . $output . PHP_EOL;

FPDI’s import support does not guarantee that every advanced feature in every input PDF survives a round trip. Test files with rotations, unusual media boxes, transparency, annotations, forms, encryption, and embedded content that matters to your users.

4. Position, scale, and transparency

Center, corner, or tiled placement

Use the page dimensions returned by getTemplateSize(). A centered mark is easiest to read; a bottom-right mark is less intrusive. For a corner placement, set $x = $size['width'] - $stampWidth - 12 and $y = $size['height'] - $stampHeight - 12. To tile, loop over both axes and call Image() repeatedly, keeping alpha low enough that body text remains legible.

Opacity and image format

The FPDF transparency example defines alpha from 0 to 1 and applies it to images and other page elements. Confirm that the backend and PDF viewers used by your project honor that setting. A PNG with an alpha channel is usually the safest source. PHP GD can composite a PNG onto a raster image while preserving alpha, but GD alone does not import PDF pages; it is not a replacement for FPDI.

Page ranges and conditional marks

To mark only selected pages, replace the loop bounds with an allow-list such as [1, 3, 5]. To skip a cover, inspect the page number or maintain metadata outside the PDF. For a per-customer label, generate a separate image or render text with the PDF backend; never interpolate untrusted text into a shell command.

5. Complete HTTP-to-PDF script

This single file combines downloading and watermarking. It writes to a temporary directory, cleans up on failure, and streams the final file to the caller. In a web framework, return the bytes with Content-Type: application/pdf and a download disposition.

<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use GuzzleHttp\Client;
use setasign\Fpdi\Fpdi;
$dir = sys_get_temp_dir() . '/wm-' . bin2hex(random_bytes(6));
if (!mkdir($dir, 0700, true) && !is_dir($dir)) {
    throw new RuntimeException('Cannot create temporary directory.');
}
$source = "$dir/source.pdf";
$stamp = "$dir/stamp.png";
$out = "$dir/result.pdf";
try {
    $http = new Client(['timeout' => 90, 'connect_timeout' => 15, 'http_errors' => false]);
    foreach ([
        'https://example.com/source.pdf' => $source,
        'https://example.com/stamp.png' => $stamp,
    ] as $url => $path) {
        $r = $http->get($url, ['sink' => $path]);
        if ($r->getStatusCode() !== 200 || filesize($path) === 0) {
            throw new RuntimeException('Failed to fetch ' . $url);
        }
    }
    $pdf = new Fpdi();
    for ($i = 1, $n = $pdf->setSourceFile($source); $i <= $n; $i++) {
        $id = $pdf->importPage($i);
        $s = $pdf->getTemplateSize($id);
        $pdf->AddPage($s['orientation'], [$s['width'], $s['height']]);
        $pdf->useTemplate($id);
        $w = min(55, $s['width'] * .30);
        $h = $w * .28;
        $pdf->SetAlpha(0.28);
        $pdf->Image($stamp, ($s['width']-$w)/2, ($s['height']-$h)/2, $w, $h, 'PNG');
        $pdf->SetAlpha(1);
    }
    $pdf->Output('F', $out);
    header('Content-Type: application/pdf');
    header('Content-Disposition: attachment; filename="watermarked.pdf"');
    readfile($out);
} finally {
    foreach (glob($dir . '/*') ?: [] as $file) { @unlink($file); }
    @rmdir($dir);
}

6. If the files are transferred by another client

The PDF editing steps do not change when another process downloads the files. These equivalents are useful for diagnostics or a mixed-language pipeline.

# cURL
curl --fail --location https://example.com/source.pdf -o source.pdf
curl --fail --location https://example.com/stamp.png -o stamp.png

# Python
import requests
for url, name in [('https://example.com/source.pdf', 'source.pdf'), ('https://example.com/stamp.png', 'stamp.png')]:
    r = requests.get(url, timeout=60)
    r.raise_for_status()
    open(name, 'wb').write(r.content)

# Node.js (Node 18+)
import { writeFile } from 'node:fs/promises';
for (const [url, name] of [['https://example.com/source.pdf','source.pdf'], ['https://example.com/stamp.png','stamp.png']]) {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`${res.status} ${url}`);
  await writeFile(name, Buffer.from(await res.arrayBuffer()));
}

7. Troubleshooting

Symptom Likely cause Fix
Unable to find PDF header The URL returned HTML, JSON, or a login page. Inspect status, Content-Type, and the first bytes; use authenticated headers and explicit error checks.
FPDI parser exception Encrypted, malformed, or unsupported PDF features. Open the file in a validator, decrypt with authorization, or test a representative alternative.
Watermark is invisible Coordinates are outside the page, alpha is zero, or the image path is wrong. Log page dimensions, set alpha to 1 temporarily, and verify is_readable() and image dimensions.
Watermark is stretched Hard-coded width and height ignore the source aspect ratio. Calculate one dimension from the image ratio.
Rotated pages look wrong Page boxes or orientation differ from assumptions. Use getTemplateSize() per page and test portrait, landscape, and rotated inputs.
Out-of-memory or timeout Large, image-heavy PDFs consume PHP memory; see the FPDF FAQ. Raise memory only when justified, process jobs asynchronously, limit upload size, and avoid loading remote bodies as strings.
Output is corrupted Warnings or debug text were emitted before PDF bytes. Disable display_errors for the endpoint, log errors separately, and send headers before readfile().

8. Reliability, performance, and cost

  • Stream inputs: Guzzle’s sink option prevents a second in-memory copy of a large download.
  • Bound work: enforce maximum bytes, page count, and wall-clock time before processing untrusted files.
  • Retry carefully: retry transient connection failures with exponential backoff, but do not blindly retry deterministic 4xx responses.
  • Keep originals: write to a new output path and retain the source until validation succeeds.
  • Observe: record source size, page count, elapsed download/render time, and parser errors without logging credentials or document contents.
  • Cost: the libraries are Composer dependencies; practical cost is PHP CPU, memory, storage, and bandwidth. Large image-heavy documents increase all four.

Or skip the browser setup

If the PDF begins as a web page you need to capture before watermarking, ScreenshotNeo can return a clean image or PDF from one request. Cookie and consent banners, newsletter popups, and chat widgets are removed before capture; bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and the response reports its verdict in X-Page-Verdict and X-Billed headers. Its MCP server exposes take_screenshot, get_page_info, and capture_pdf to Claude, Cursor, and other MCP clients.

See the ScreenshotNeo API documentation for full-page capture, CSS-selector element capture, custom CSS and JavaScript, waits, headers, cookies, device presets, PDF paper size and margins, caching, signed links, async jobs, and bulk capture.

# cURL
curl -G 'https://api.screenshotneo.com/v1/shot' -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

# Python
import requests
r = requests.get('https://api.screenshotneo.com/v1/shot', params={'access_key': 'YOUR_API_KEY', 'url': 'https://stripe.com'}, timeout=90)
open('shot.webp', 'wb').write(r.content)

# Node.js
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

1,000 shots per month are free with no card. Paid plans start at $5 for 3,000 shots, and every feature is on every plan. Create a free ScreenshotNeo account.

FAQ

Can I use Guzzle alone to watermark a PDF?

No. Guzzle transfers bytes; FPDI plus FPDF or TCPDF performs page import and drawing.

Which backend should I choose, FPDF or TCPDF?

Use the one your project already depends on and verify transparency and input compatibility with real documents. FPDI supports both.

Do not assume it. Test annotations, forms, encryption, and other advanced features in representative files after import.

How do I watermark only one page?

Import every page to preserve the document, but call Image() only when the page number is in your selected set.

Can I return the result without writing it to disk?

Yes, use the backend’s string output mode and stream it, but disk-backed temporary files are safer for large inputs and simplify cleanup.