ScreenshotNeo

BlogHTML to image & PDF

How to Add a Text Watermark to a PDF with PHP Guzzle

Download a PDF with Guzzle, watermark every page with FPDI and TCPDF, validate inputs, handle edge cases, and return the finished file safely.

By the ScreenshotNeo team1 October 20268 min read

Short answer: Guzzle downloads or uploads the PDF; it does not draw on PDF pages. Use Guzzle for HTTP, FPDI to import each existing page, and TCPDF to draw the watermark text. The reliable flow is to download the source to a private temporary file, import every page while preserving its dimensions and orientation, draw the text on an overlay, write the result, and delete temporary files in a finally block.

What each library does

Component Responsibility
Guzzle HTTP transport: download the source PDF and optionally upload the finished file.
FPDI Imports pages from the existing PDF so they can be placed in a new document.
TCPDF Creates output pages and draws text, color, transparency, rotation and other graphics.

A watermark is applied once for each page you intend to mark. The output is a rewritten PDF, so digital signatures, encryption settings and other document properties may change.

Install the dependencies

composer require guzzlehttp/guzzle setasign/fpdi-tcpdf

Pin versions that match your PHP runtime and review the API documentation for those exact versions. FPDI and TCPDF method signatures can differ between major releases.

Complete PHP example: download, watermark, and save

The following controller-style example streams the source to disk, validates the response, preserves each page’s size and orientation, applies a centered diagonal watermark, and cleans up temporary files. Replace the URLs and watermark text for your application.

<?php

require __DIR__ . '/vendor/autoload.php';

use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use setasign\Fpdi\Tcpdf\Fpdi;

$sourceUrl = 'https://example.com/source.pdf';
$destinationUrl = null; // Set to an upload endpoint when needed.
$watermarkText = 'CONFIDENTIAL';

$inputPath = tempnam(sys_get_temp_dir(), 'pdf-in-');
$outputPath = tempnam(sys_get_temp_dir(), 'pdf-out-');

if ($inputPath === false || $outputPath === false) {
    throw new RuntimeException('Could not create a temporary file.');
}

$http = new Client([
    'timeout' => 30,
    'connect_timeout' => 10,
    'http_errors' => false,
]);

try {
    $response = $http->request('GET', $sourceUrl, [
        'sink' => $inputPath,
        'headers' => ['Accept' => 'application/pdf'],
    ]);

    $status = $response->getStatusCode();
    if ($status < 200 || $status >= 300) {
        throw new RuntimeException("Source returned HTTP {$status}.");
    }

    $size = filesize($inputPath);
    if ($size === false || $size === 0) {
        throw new RuntimeException('The downloaded file is empty.');
    }

    // Do not trust Content-Type alone: remote services sometimes return HTML errors.
    $handle = fopen($inputPath, 'rb');
    $signature = $handle ? fread($handle, 5) : false;
    if (is_resource($handle)) {
        fclose($handle);
    }
    if ($signature !== '%PDF-') {
        throw new RuntimeException('The response is not a PDF.');
    }

    $pdf = new Fpdi();
    $pageCount = $pdf->setSourceFile($inputPath);

    for ($pageNo = 1; $pageNo <= $pageCount; $pageNo++) {
        $templateId = $pdf->importPage($pageNo);
        $pageSize = $pdf->getTemplateSize($templateId);
        $width = $pageSize['width'];
        $height = $pageSize['height'];
        $orientation = $width > $height ? 'L' : 'P';

        $pdf->AddPage($orientation, [$width, $height]);
        $pdf->useTemplate($templateId);

        // Draw the watermark above the imported page.
        $pdf->SetAlpha(0.20);
        $pdf->SetFont('helvetica', 'B', 28);
        $pdf->SetTextColor(120, 120, 120);
        $pdf->StartTransform();
        $pdf->Rotate(45, $width / 2, $height / 2);
        $pdf->Text(35, $height / 2, $watermarkText);
        $pdf->StopTransform();
        $pdf->SetAlpha(1);
    }

    $pdf->Output($outputPath, 'F');

    // Return this file from your framework, or upload it as shown below.
    if ($destinationUrl !== null) {
        $upload = $http->request('PUT', $destinationUrl, [
            'headers' => ['Content-Type' => 'application/pdf'],
            'body' => fopen($outputPath, 'rb'),
        ]);
        if ($upload->getStatusCode() < 200 || $upload->getStatusCode() >= 300) {
            throw new RuntimeException('The destination rejected the PDF.');
        }
    }

    // Example for a plain PHP response:
    header('Content-Type: application/pdf');
    header('Content-Disposition: attachment; filename="watermarked.pdf"');
    readfile($outputPath);
} catch (GuzzleException | RuntimeException $exception) {
    http_response_code(502);
    error_log($exception->getMessage());
    echo 'Unable to process the PDF.';
} finally {
    @unlink($inputPath);
    @unlink($outputPath);
}

This is an implementation pattern. Verify the exact FPDI/TCPDF signatures against the versions pinned in your project before deployment.

Control the watermark appearance

  • Opacity: SetAlpha(0.20) makes the text translucent. Restore alpha to 1 after drawing so later content is unaffected.
  • Font: change the family, style and size in SetFont(). Larger text is easier to notice but can cover document content.
  • Color: SetTextColor(120, 120, 120) uses an RGB gray.
  • Position: adjust the Text() coordinates. Coordinates are in the page’s unit system, usually millimeters.
  • Rotation: wrap Text() in StartTransform(), Rotate() and StopTransform().
  • Page selection: loop over all pages, or conditionally watermark only selected page numbers.
// Watermark only pages 1 through 3.
for ($pageNo = 1; $pageNo <= $pageCount; $pageNo++) {
    // import and add the page as above
    if ($pageNo >= 1 && $pageNo <= 3) {
        $pdf->SetAlpha(0.20);
        $pdf->SetFont('helvetica', 'B', 28);
        $pdf->Text(35, $height / 2, 'DRAFT');
        $pdf->SetAlpha(1);
    }
}

Test portrait, landscape, mixed-size and unusually sized pages. A fixed coordinate that looks centered on A4 may not be centered on a receipt or poster.

Using a wrapper package

If you prefer configuration over page-by-page drawing, the tomedio/pdf-watermark project provides a wrapper around FPDI-based processing. Its documented controls include font size, color, opacity, font style, background, rotation, position, page ranges and page-number placeholders. It modifies existing pages rather than adding new ones and recognizes page sizes and orientations.

$textConfig = $factory->createTextWatermarkConfig('CONFIDENTIAL');
$textConfig
    ->setPosition(AbstractWatermark::POSITION_CENTER)
    ->setOpacity(0.20)
    ->setFontSize(28)
    ->setTextColor(120, 120, 120);

$watermarker = $factory->createWithTextWatermark($textConfig);
$watermarker->apply($inputPath, $outputPath);

Use the current README and release constraints for the exact factory namespace and constructor code. The wrapper’s API can change across releases.

Downloading with other clients

Guzzle is the PHP transport in the main solution. These equivalent download commands are useful when the PDF is fetched by another service or job.

cURL

curl --fail --location --output source.pdf https://example.com/source.pdf

Python

import requests

with requests.get('https://example.com/source.pdf', stream=True, timeout=30) as response:
    response.raise_for_status()
    with open('source.pdf', 'wb') as output:
        for chunk in response.iter_content(chunk_size=1024 * 1024):
            if chunk:
                output.write(chunk)

Node.js

const fs = require('node:fs');

const response = await fetch('https://example.com/source.pdf');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const buffer = Buffer.from(await response.arrayBuffer());
fs.writeFileSync('source.pdf', buffer);

Those clients only retrieve bytes. The watermark still requires a PDF processing library in the runtime that creates the output.

Compressed and newer PDF versions

Some compressed PDFs, especially versions higher than 1.4, may not be directly processable by FPDI. The documented compatibility workaround is to uncompress the input with pdftk, apply the watermark, and recompress the output. Treat the downloaded file as untrusted input and isolate external commands.

pdftk source.pdf output source-uncompressed.pdf uncompress
# Run the FPDI/TCPDF process against source-uncompressed.pdf.
pdftk watermarked.pdf output watermarked-compressed.pdf compress

Check the command’s exit status, use absolute executable paths where appropriate, apply resource limits, and never interpolate an untrusted filename into a shell command.

Validation and production safety checklist

  • Require a successful HTTP status before processing.
  • Enforce a maximum download size before accepting the file.
  • Check the first bytes for the %PDF- signature; reject HTML error pages.
  • Use unpredictable, private temporary filenames and restrictive permissions.
  • Always remove input and output files in finally, including failure paths.
  • Set connect and total timeouts, and decide whether retries are safe for the source endpoint.
  • Keep external tools such as pdftk isolated from the web process.
  • Log status, duration, page count and failure reason without logging sensitive PDF contents.

Troubleshooting

Symptom Likely cause Fix
“The response is not a PDF” The URL returned HTML, a login page or an API error. Inspect status, Content-Type and the first bytes; authenticate the request and use the actual PDF URL.
FPDI cannot parse the file Unsupported compression, a newer PDF version, corruption or encryption. Try the documented pdftk uncompress step, validate the source independently, and test whether the file is encrypted or malformed.
Watermark is missing Text was drawn before the imported template or alpha remained transparent. Import and place the template first, draw afterward, and restore alpha to 1.
Watermark is cut off Coordinates or rotation do not fit the page. Calculate placement from the imported width and height; test both orientations and margin-heavy documents.
Output pages have the wrong orientation A fixed orientation or page size was used. Read getTemplateSize() for every page and pass its dimensions to AddPage().
Memory or timeout errors Large files, many pages or high-resolution content. Stream downloads, process jobs asynchronously, set explicit limits, and remove temporary files promptly.
Signed PDF no longer verifies Rewriting a PDF changes its byte structure. Do not watermark signed documents when signature preservation is required; obtain a new signature after processing.

Performance, reliability and cost considerations

Downloading to a sink avoids holding the entire source body in a PHP string. Processing time and memory depend on page count, page dimensions, embedded images and the PDF parser. For large documents, move work to a queue, cap input size and page count, and return a job identifier rather than holding an HTTP request open.

Retries should be limited to transient network failures. Retrying the PDF rewrite itself can create duplicate uploads unless the destination supports idempotency. Keep source and output files on private storage and delete them after the retention period required by your workflow.

The main cost drivers are network transfer, CPU time for parsing and rewriting, temporary storage and any external conversion process. Measure representative portrait, landscape, image-heavy and high-page-count files before choosing worker limits.

Or skip the browser setup

If the job is to capture a web page as an image or PDF rather than watermark an existing PDF, ScreenshotNeo provides a single HTTP request. Its API accepts the URL and returns a PNG, JPEG, WebP or PDF. Cookie and consent banners, newsletter popups and chat widgets are removed before capture; bot checks, blank pages and failed loads are not billed. An MCP server lets AI agents take screenshots, inspect pages and capture PDFs.

See the ScreenshotNeo API documentation for request options. A cURL request looks like this:

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}`);

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

FAQ

Can Guzzle add the watermark by itself?

No. Guzzle transports HTTP data. FPDI and TCPDF, or a wrapper built on them, perform the PDF rewrite.

Can I watermark only selected pages?

Yes. Import every page you want to preserve, and draw the watermark only when its page number matches your selection.

Will the original PDF be changed?

No. The usual flow reads the source and writes a new output file. The rewritten file can have different signatures, encryption and metadata.

Why validate the PDF signature?

A successful HTTP response can still contain an HTML error page or login form. Checking for %PDF- catches that failure before the parser runs.

What should I do with encrypted or malformed PDFs?

There is no universal guarantee that FPDI can process them. Test representative files, handle parser failures, and decide whether to reject, decrypt through an authorized workflow, or use another conversion path.