Wer eigene Fotos ins Netz stellt, kennt die Sorge: Einmal veröffentlicht, taucht das Bild irgendwann anderswo wieder auf, ohne Nennung des Urhebers. Ein sichtbarer Hinweis direkt im Bild ist der einfachste Schutz davor, und PHP bringt mit der GD-Bibliothek alles mit, um ihn automatisiert anzubringen. Dieses Tutorial zeigt beide Varianten, Text und Logo, dazu Positionsberechnung, Transparenz, Tile-Muster und die Verarbeitung ganzer Verzeichnisse.
Was leistet ein PHP Wasserzeichen?
Ein PHP Wasserzeichen schützt eigene oder kommerzielle Fotos vor unbefugter Weiternutzung und macht Urheberschaft auf einen Blick sichtbar. Mit der GD-Bibliothek lässt sich automatisiert ein Copyright-Hinweis als Text erstellen oder ein Logo als halbtransparentes PNG über jedes Bild legen, ohne dass jemand mit Photoshop oder GIMP arbeiten muss. Typische Beispiele sind Foto-Galerien, Open-Graph-Bilder für Social Media oder Bilder-Pipelines für Online-Shops.

Bevor imagettftext() und imagecopymerge() zum Einsatz kommen, ein Blick auf die Entscheidung Text gegen Logo und auf die typischen Setups in der Praxis.
Dieses Tutorial zeigt die zwei Hauptvarianten Schritt für Schritt: Text-Wasserzeichen mit imagettftext() und Bild-Wasserzeichen mit imagecopymerge() oder imagecopy(). Ergänzt werden Hinweise zur Positionsberechnung, Transparenz, Tile-Patterns und Batch-Verarbeitung eines kompletten Verzeichnisses.
Text-Wasserzeichen oder Bild-Wasserzeichen?
Bevor wir am ersten PHP Wasserzeichen schrauben, lohnt sich ein kurzer Blick auf die zwei Hauptvarianten.
Ein Text-Wasserzeichen ist klein, schnell, frei anpassbar und braucht nur eine TTF-Schrift. Es eignet sich für Copyright-Hinweise und dezente Markierungen. Ein Bild-Wasserzeichen mit Logo wirkt professioneller, ist optisch stabiler und transportiert die Marke. Es braucht eine PNG-Datei mit Transparenz und etwas mehr Speicher.
In der Praxis kombinieren viele Setups beides: ein dezentes Logo unten rechts plus einen Copyright-Hinweis am unteren Rand. Wer maximalen Schutz vor Bilder-Klau braucht, verteilt das Wasserzeichen als Tile-Pattern flächig über das gesamte Bild, damit es sich nicht durch einfaches Croppen entfernen lässt.
Text-Wasserzeichen mit imagettftext und Schatten
Das einfachste PHP Wasserzeichen ist eine Textzeile mit imagettftext(). Wichtig ist ein leichter Schatten-Effekt, damit der Text auf hellen wie auf dunklen Hintergründen lesbar bleibt. Der Trick besteht aus zwei Aufrufen mit ein bis zwei Pixel Versatz und unterschiedlichen Farben.
<?php
$bild = imagecreatefromjpeg(__DIR__ . '/foto.jpg');
$schatten = imagecolorallocatealpha($bild, 0, 0, 0, 70);
$text = imagecolorallocatealpha($bild, 255, 255, 255, 30);
$schrift = __DIR__ . '/fonts/OpenSans-Regular.ttf';
$inhalt = '(c) 2026 Mein Studio';
/* zuerst Schatten leicht versetzt, dann der eigentliche Text */
imagettftext($bild, 18, 0, 21, 41, $schatten, $schrift, $inhalt);
imagettftext($bild, 18, 0, 20, 40, $text, $schrift, $inhalt);
imagejpeg($bild, __DIR__ . '/foto_wm.jpg', 90);
imagedestroy($bild);
imagecolorallocatealpha() erlaubt einen vierten Parameter für die Transparenz, von 0 (deckend) bis 127 (vollständig durchsichtig). Der Schrift-Pfad muss absolut sein, sonst sucht GD im aktuellen Working-Directory des Servers und scheitert leise. Ein typischer Wert wie __DIR__ . '/fonts/...' ist die robuste Variante, die unabhängig vom Aufrufort funktioniert.
Position für das PHP Wasserzeichen mit imagettfbbox berechnen
Bei festem Text und unterschiedlich großen Bildern muss die Position vorab berechnet werden. imagettfbbox() liefert die Bounding Box des Textes vor dem eigentlichen Zeichnen, sodass sich die Textbreite ermitteln und die Position rechts unten oder zentriert berechnen lässt.
<?php
$bild = imagecreatefromjpeg(__DIR__ . '/foto.jpg');
$breite = imagesx($bild);
$hoehe = imagesy($bild);
$schrift = __DIR__ . '/fonts/OpenSans-Regular.ttf';
$inhalt = '(c) Mein Studio';
$bbox = imagettfbbox(18, 0, $schrift, $inhalt);
$textBreite = $bbox[2] - $bbox[0];
$rand = 15;
$x = $breite - $textBreite - $rand;
$y = $hoehe - $rand;
$farbe = imagecolorallocatealpha($bild, 255, 255, 255, 30);
imagettftext($bild, 18, 0, $x, $y, $farbe, $schrift, $inhalt);
imagejpeg($bild, __DIR__ . '/foto_wm_re.jpg', 90);
imagedestroy($bild);
Die Bounding Box ist ein Array mit acht Werten, jeweils x und y für die vier Eckpunkte. Die Breite ist $bbox[2] - $bbox[0], die Höhe $bbox[1] - $bbox[7]. Bei der y-Koordinate von imagettftext() muss beachtet werden, dass der Wert die Schriftbasislinie meint, nicht den oberen Rand des Buchstabens. Der Abstand zum unteren Bildrand sollte deshalb größer sein als die Schriftgröße, damit nichts abgeschnitten wird.
PNG-Logo als PHP Wasserzeichen halbtransparent einbetten mit imagecopymerge
Wer statt Text ein Logo als PHP Wasserzeichen einbetten möchte, lädt das Logo als zweites Bild und kopiert es mit imagecopymerge() an die gewünschte Position. Der letzte Parameter ist der Deckkraft-Wert in Prozent: 100 ist deckend, 0 ist unsichtbar. Praxisbewährt sind 50 bis 70 Prozent für ein dezentes, aber sichtbares Logo.
<?php
$foto = imagecreatefromjpeg(__DIR__ . '/foto.jpg');
$logo = imagecreatefrompng(__DIR__ . '/logo.png');
$logoBreite = imagesx($logo);
$logoHoehe = imagesy($logo);
$x = imagesx($foto) - $logoBreite - 20;
$y = imagesy($foto) - $logoHoehe - 20;
/* 60 = 60% Deckkraft */
imagecopymerge($foto, $logo, $x, $y, 0, 0, $logoBreite, $logoHoehe, 60);
imagejpeg($foto, __DIR__ . '/foto_logo.jpg', 90);
imagedestroy($foto);
imagedestroy($logo);
imagecopymerge() ist die schnelle Variante für einheitliche Transparenz, ignoriert aber den eingebauten Alpha-Kanal eines PNG. Wer ein Logo mit weichen Kanten oder freigestellten Bereichen verwendet, sollte stattdessen den nächsten Abschnitt mit imagecopy() und Alpha-Optionen lesen.
Transparenz und Alpha-Kanal richtig setzen
Ein typischer Fehler ist, dass ein freigestelltes PNG-Logo am Ende mit weißem Hintergrund-Block erscheint. Das liegt daran, dass GD den Alpha-Kanal nicht automatisch berücksichtigt. Vier kurze Aufrufe lösen das Problem.
<?php
$foto = imagecreatefromjpeg(__DIR__ . '/foto.jpg');
$logo = imagecreatefrompng(__DIR__ . '/logo_alpha.png');
/* Alpha-Kanal des Logos respektieren */
imagealphablending($foto, true);
imagesavealpha($foto, true);
imagealphablending($logo, true);
imagesavealpha($logo, true);
$x = imagesx($foto) - imagesx($logo) - 20;
$y = imagesy($foto) - imagesy($logo) - 20;
imagecopy($foto, $logo, $x, $y, 0, 0, imagesx($logo), imagesy($logo));
imagejpeg($foto, __DIR__ . '/foto_alpha.jpg', 90);
imagedestroy($foto);
imagedestroy($logo);
imagealphablending(true) weist GD an, neue Farben mit den vorhandenen zu mischen. imagesavealpha(true) sorgt dafür, dass der Alpha-Wert beim Speichern nicht verloren geht. Mit dieser Kombination und imagecopy() statt imagecopymerge() werden auch komplexe Logos mit Verläufen und Schatten korrekt eingebettet.
Diagonal-Wasserzeichen mit imagerotate
Wer einen Stock-Foto-Look erreichen möchte, dreht das Wasserzeichen schräg über das Bild. imagettftext() akzeptiert als dritten Parameter einen Drehwinkel, sodass Text-Wasserzeichen direkt schräg geschrieben werden können. Der Winkel wird in Grad angegeben, gegen den Uhrzeigersinn gemessen.
<?php
$bild = imagecreatefromjpeg(__DIR__ . '/foto.jpg');
$schrift = __DIR__ . '/fonts/OpenSans-Bold.ttf';
$inhalt = '(c) Mein Studio';
$farbe = imagecolorallocatealpha($bild, 255, 255, 255, 60);
$winkel = 30;
/* zentriert und um 30 Grad gedreht */
$bbox = imagettfbbox(48, $winkel, $schrift, $inhalt);
$textBreite = abs($bbox[2] - $bbox[0]);
$textHoehe = abs($bbox[1] - $bbox[7]);
$x = (int) ((imagesx($bild) - $textBreite) / 2);
$y = (int) ((imagesy($bild) + $textHoehe) / 2);
imagettftext($bild, 48, $winkel, $x, $y, $farbe, $schrift, $inhalt);
imagejpeg($bild, __DIR__ . '/foto_diag.jpg', 90);
imagedestroy($bild);
Für ein gedrehtes Logo (also kein Text, sondern ein PNG) gibt es imagerotate(). Das gedrehte Logo bekommt einen transparenten Hintergrund, sodass es sauber über das Foto kopiert werden kann.
<?php
$foto = imagecreatefromjpeg(__DIR__ . '/foto.jpg');
$logo = imagecreatefrompng(__DIR__ . '/logo.png');
/* transparente Farbe als Hintergrund fuer das gedrehte Bild */
$transparent = imagecolorallocatealpha($logo, 0, 0, 0, 127);
$gedreht = imagerotate($logo, 30, $transparent);
imagealphablending($gedreht, true);
imagesavealpha($gedreht, true);
/* Logo zentriert ueber das Foto legen */
$x = (int) ((imagesx($foto) - imagesx($gedreht)) / 2);
$y = (int) ((imagesy($foto) - imagesy($gedreht)) / 2);
imagecopy($foto, $gedreht, $x, $y, 0, 0, imagesx($gedreht), imagesy($gedreht));
imagejpeg($foto, __DIR__ . '/foto_logo_rot.jpg', 90);
imagedestroy($foto);
imagedestroy($logo);
imagedestroy($gedreht);
imagerotate() arbeitet im Uhrzeigersinn negativ, gegen den Uhrzeigersinn positiv, im Gegensatz zu imagettftext() aber mit umgekehrtem Vorzeichen. Wer den Winkel nicht passend findet, dreht einfach das Vorzeichen um. Die Funktion liefert immer eine neue Image-Resource zurück, das Original bleibt unverändert.
Tile-Pattern: das PHP Wasserzeichen flächig verteilen
Ein einzelnes PHP Wasserzeichen rechts unten lässt sich mit einem Crop schnell entfernen. Wer den Bilder-Klau wirklich erschweren möchte, verteilt das Logo gleichmäßig über das ganze Bild. Mit zwei verschachtelten Schleifen ist das in wenigen Zeilen erledigt.
<?php
$foto = imagecreatefromjpeg(__DIR__ . '/foto.jpg');
$logo = imagecreatefrompng(__DIR__ . '/logo.png');
$lb = imagesx($logo);
$lh = imagesy($logo);
/* Logo in Abstaenden ueber das ganze Bild verteilen */
for ($y = 0; $y < imagesy($foto); $y += $lh + 80) {
for ($x = 0; $x < imagesx($foto); $x += $lb + 120) {
imagecopymerge($foto, $logo, $x, $y, 0, 0, $lb, $lh, 25);
}
}
imagejpeg($foto, __DIR__ . '/foto_tiles.jpg', 90);
imagedestroy($foto);
imagedestroy($logo);
Die niedrige Deckkraft von 25 Prozent stellt sicher, dass das Foto noch erkennbar bleibt, der Schutz aber wirksam ist. Die Abstände von 80 und 120 Pixeln steuern, wie dicht die Marker liegen. Bei sehr großen Bildern dürfen die Abstände wachsen, damit das Bild nicht zu unruhig wird.
Der folgende Ablauf fasst beide Wege vom Original bis zur fertigen Datei zusammen.
flowchart TD
A[Original-Foto laden] --> B{Wasserzeichen-Typ?}
B -->|Text| C[imagettftext mit Schrift]
B -->|Logo| D[Logo als PNG laden]
C --> E[Position via imagettfbbox]
D --> F[imagecopy oder imagecopymerge]
E --> G[imagejpeg speichern]
F --> G
G --> H[imagedestroy]
Batch-Verarbeitung des PHP Wasserzeichen über ein ganzes Verzeichnis
In der Praxis liegt selten nur ein Foto vor. Mit glob() lässt sich ein Verzeichnis nach Mustern durchsuchen, und das PHP Wasserzeichen wird dann pro Datei angebracht. Wichtig: das Logo nur einmal laden, nicht in jeder Iteration, sonst kostet das spürbar Zeit und Speicher.
<?php
$logo = imagecreatefrompng(__DIR__ . '/logo.png');
imagealphablending($logo, true);
imagesavealpha($logo, true);
$lb = imagesx($logo);
$lh = imagesy($logo);
foreach (glob(__DIR__ . '/quellen/*.jpg') as $datei) {
$foto = imagecreatefromjpeg($datei);
imagealphablending($foto, true);
$x = imagesx($foto) - $lb - 20;
$y = imagesy($foto) - $lh - 20;
imagecopy($foto, $logo, $x, $y, 0, 0, $lb, $lh);
$ziel = __DIR__ . '/ziel/' . basename($datei);
imagejpeg($foto, $ziel, 90);
imagedestroy($foto);
}
imagedestroy($logo);
Damit lassen sich auch hundert Fotos in einem Rutsch markieren. Wer noch mehr Performance braucht, verlagert die Verarbeitung in einen eigenen CLI-Prozess, der parallel zur Webseite läuft, oder nutzt Worker-Queues für die asynchrone Bearbeitung.
Wasserzeichen on-the-fly mit mod_rewrite und Caching
In Foto-Galerien soll das Original meist unberührt bleiben, das Wasserzeichen erst beim Ausliefern an den Browser entstehen. Der Trick ist eine Kombination aus mod_rewrite, einem Skript-Endpoint und einem Datei-Cache. Der Browser ruft eine URL wie /img/wm/foto.jpg auf, mod_rewrite leitet auf ein PHP-Skript um, das beim ersten Aufruf das Wasserzeichen rechnet und die fertige Datei in einem Cache-Verzeichnis ablegt.
<?php
/*
* .htaccess in /img/:
*
* RewriteEngine On
* RewriteCond %{REQUEST_FILENAME} !-f
* RewriteRule ^wm/(.+\.jpe?g)$ /watermark.php?file=$1 [L,QSA]
*/
/* watermark.php */
$datei = basename($_GET['file'] ?? '');
$quelle = __DIR__ . '/img/originals/' . $datei;
$cache = __DIR__ . '/img/wm-cache/' . $datei;
if (!is_file($quelle)) {
http_response_code(404);
exit;
}
/* Cache-Hit: gecachte Datei direkt streamen */
if (is_file($cache) && filemtime($cache) >= filemtime($quelle)) {
header('Content-Type: image/jpeg');
header('Cache-Control: public, max-age=31536000, immutable');
readfile($cache);
exit;
}
/* Cache-Miss: Wasserzeichen rechnen und im Cache ablegen */
$foto = imagecreatefromjpeg($quelle);
$logo = imagecreatefrompng(__DIR__ . '/logo.png');
imagealphablending($foto, true);
imagecopy(
$foto, $logo,
imagesx($foto) - imagesx($logo) - 20,
imagesy($foto) - imagesy($logo) - 20,
0, 0, imagesx($logo), imagesy($logo)
);
imagejpeg($foto, $cache, 90);
imagedestroy($foto);
imagedestroy($logo);
header('Content-Type: image/jpeg');
header('Cache-Control: public, max-age=31536000, immutable');
readfile($cache);
Drei Punkte sind hier sicherheits- und performance-kritisch. Erstens basename() als Path-Traversal-Schutz: wer ../../../etc/passwd reinschickt, bekommt nur passwd zurück. Zweitens der filemtime()-Vergleich: wenn das Original neuer ist als der Cache, wird das Wasserzeichen neu gerechnet, sonst gilt der Cache als gültig. Drittens die langen Cache-Header, damit Browser und CDN die Datei einmal holen und dann lokal vorhalten. Eine sinnvolle Ergänzung ist ein Cron-Job, der alte Cache-Dateien regelmässig löscht, falls Originale entfernt wurden.
Memory-Limit und grosse Fotos
GD lädt jedes Bild komplett in den Speicher und braucht pro Pixel etwa 4 Byte. Ein 4000 mal 3000 Pixel grosses Foto frisst also rund 48 MB Heap, plus ein Vielfaches für Zwischenoperationen. Beim Default-memory_limit von 128 MB reichen ein zweites geladenes Bild und eine Rotation, um den Speicher zu sprengen. Der Aufruf scheitert dann mit "Allowed memory size exhausted".
<?php
/* Speicher-Check vor dem Laden grosser Bilder */
$info = getimagesize($pfad);
$benoetigt = $info[0] * $info[1] * 4; /* 4 Byte pro Pixel */
$puffer = max($benoetigt * 3, 64 * 1024 * 1024); /* mit Puffer */
ini_set('memory_limit', (int) ($puffer / (1024 * 1024)) . 'M');
$bild = imagecreatefromjpeg($pfad);
/* ... Wasserzeichen anbringen ... */
imagedestroy($bild);
Drei Strategien helfen, den Speicherbedarf in den Griff zu bekommen. Erstens das Original vor dem Wasserzeichen verkleinern, falls die Galerie ohnehin nur 1600 Pixel braucht. Zweitens imagedestroy() direkt nach Verwendung aufrufen, statt auf das Skript-Ende zu warten. Drittens den Wasserzeichen-Job in einen separaten CLI-Prozess auslagern, damit das memory_limit der Web-Anfrage unangetastet bleibt. Wer regelmässig hochauflösende RAW-Konvertierungen verarbeitet, sollte ohnehin auf Imagick umsteigen, das Streaming-fähig ist und mit gleichem Speicher grössere Bilder schafft.
PDF-Wasserzeichen kurz erklärt
Die GD-Bibliothek arbeitet ausschliesslich auf Pixel-Bildern. Für Wasserzeichen in PDF-Dateien helfen spezialisierte PHP-Bibliotheken: TCPDF und FPDI zusammen erlauben das Einlesen einer bestehenden PDF und das Überlagern mit eigenem Inhalt. setasign/fpdf-tpl plus setasign/fpdi sind die kommerzielle Variante mit stärkerem Funktionsumfang. Die Logik bleibt strukturell gleich: Quelle laden, Seite für Seite importieren, Text oder Bild als Layer darüber rendern, Ergebnis als neue PDF speichern.
<?php
/*
* composer require setasign/fpdi tecnickcom/tcpdf
*
* use setasign\Fpdi\Tcpdf\Fpdi;
*
* $pdf = new Fpdi();
* $pageCount = $pdf->setSourceFile('vertrag.pdf');
* for ($i = 1; $i <= $pageCount; $i++) {
* $tpl = $pdf->importPage($i);
* $size = $pdf->getTemplateSize($tpl);
* $pdf->AddPage($size['orientation'], [$size['width'], $size['height']]);
* $pdf->useTemplate($tpl);
*
* $pdf->SetFont('helvetica', 'B', 60);
* $pdf->SetTextColor(200, 200, 200);
* $pdf->StartTransform();
* $pdf->Rotate(45, $size['width'] / 2, $size['height'] / 2);
* $pdf->Text(20, $size['height'] / 2, 'VERTRAULICH');
* $pdf->StopTransform();
* }
* $pdf->Output('vertrag_wm.pdf', 'F');
*/
PDF-Wasserzeichen sind also kein GD-Thema, sondern ein eigenes Ökosystem. Wer regelmässig Verträge oder Rechnungen kennzeichnen muss, plant TCPDF plus FPDI als feste Library-Kombination ein.
Stolperfallen und Performance-Tipps
Drei typische Fehler tauchen bei PHP Wasserzeichen immer wieder auf. Erstens: TTF-Pfade nicht absolut, also __DIR__ statt relativem Pfad. Zweitens: imagecopymerge() mit Alpha-PNG erwartet, dass GD selbst den Alpha-Kanal mischt; das tut die Funktion aber nicht. Stattdessen imagecopy() mit imagealphablending(true). Drittens: Bei vielen Bildern wird das Logo immer wieder neu geladen; ein einmaliges Laden ausserhalb der Schleife spart wertvolle Sekunden.
Performance-seitig hilft es, das Logo nicht größer zu speichern als es später eingesetzt wird. Ein Logo, das später mit 200 mal 80 Pixel auftaucht, sollte auch in dieser Auflösung vorliegen, sonst rechnet GD bei jeder Kopie mit unnötigen Pixeln. Das Speichern als JPG mit Qualität 85 bis 90 ergibt einen guten Kompromiss zwischen Dateigröße und Optik. Wer noch kleinere Files braucht, kombiniert das Wasserzeichen mit einem WEBP-Output und spart erneut 25 bis 35 Prozent gegenüber JPG.
Fazit zum PHP Wasserzeichen
Ein PHP Wasserzeichen mit der GD-Bibliothek ist mit wenigen Funktionen umsetzbar und reicht von dezenten Copyright-Hinweisen bis zum flächendeckenden Tile-Pattern. Wichtig sind die Pflichtreihenfolge "Bild laden, Logo laden, kopieren, speichern", absolute TTF-Pfade und das richtige Setzen der Alpha-Optionen, wenn ein freigestelltes PNG zum Einsatz kommt. Mit imagettftext() plus Schatten und imagecopy() plus imagealphablending() stehen alle Bausteine bereit, um eigene Fotos professionell zu schützen. Im Batch-Betrieb mit glob() lassen sich ganze Verzeichnisse automatisiert markieren, ohne dass externe Software wie ImageMagick gebraucht wird.