Die Artikel einer selbst gebauten Website liegen in einer MySQL-Tabelle, und neue Beiträge brauchen Tage, bis Google sie findet. Wer WordPress nutzt, bekommt seit Version 5.5 eine Sitemap unter /wp-sitemap.xml vom System. Eine eigene PHP-Anwendung muss das selbst erledigen, und Online-Generatoren helfen wenig, weil sie die Seite nur von außen abgrasen und das Änderungsdatum nicht kennen. Wer eine PHP-Sitemap erstellen will, liest die Einträge deshalb direkt aus der Datenbank.
Gemessen wurde unter Windows und Linux mit PHP 8.4 gegen MySQL 8.0, mit einer Testtabelle von 50.000 Einträgen.
Was in eine XML-Sitemap gehört
Eine XML-Sitemap listet die Adressen, die Suchmaschinen wie Google und Bing kennen sollen. Pflicht sind nur drei Elemente: urlset als Rahmen, darin je Seite ein url mit der absoluten Adresse in loc. Optional ist lastmod, das einzige Zusatzfeld, das Google nutzt. Bei Google Search Central (Stand Juli 2026) heißt es wörtlich: „Google ignores <priority> and <changefreq> values.“ lastmod wertet Google aus, wenn der Wert „consistently and verifiably […] accurate“ ist.
Eine Sitemap ist laut derselben Quelle nur ein Hinweis, keine Garantie für die Indexierung. Hinein gehören nur kanonische Adressen, die mit Status 200 antworten, keine Weiterleitungen und keine Entwürfe. Neben dieser Sitemap für Seiten kennt Google Erweiterungen für Bilder, Videos und News, für ein Artikelverzeichnis genügt die einfache Form. Bevor das Skript die PHP-Sitemap erstellen kann, braucht es also saubere Adressen und ein verlässliches Änderungsdatum. Der ganze Ablauf:
flowchart TD
A[Cronjob startet] --> B[Einträge aus MySQL]
B --> C[XMLWriter schreibt url-Einträge]
C --> D{mehr als 50.000 URLs?}
D -- nein --> E[sitemap.xml]
D -- ja --> F[Teildateien und Sitemap-Index]
E --> G[temporäre Datei umbenennen]
F --> G
G --> H[robots.txt nennt die Sitemap]
H --> I[Einreichen in der Search Console]
Aufbau der sitemap.xml: urlset, url, loc und lastmod
So sieht eine gültige Datei mit einem Eintrag aus, darunter die Bedeutung der Elemente nach dem Sitemaps-Protokoll von sitemaps.org:
<?xml version="1.0" encoding="UTF-8"?>
<urlset
xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://www.example.de/artikel/a.html</loc>
<lastmod>2026-10-24T13:24:00+02:00</lastmod>
</url>
</urlset>
<!-- urlset - required, encapsulates the file and
declares the protocol namespace
url - required, parent tag for each entry
loc - required, absolute URL of the page,
entity escaped, less than 2048 chars
lastmod - optional, date of last modification
in W3C Datetime format, YYYY-MM-DD
or with time and time zone
changefreq, priority - optional, ignored by
Google -->
Jedes Skript, das eine PHP-Sitemap erstellen soll, muss genau diese Struktur erzeugen. Ohne den Namensraum in xmlns lehnt die Prüfung gegen das offizielle Schema die Datei ab, kodiert wird in UTF-8.
PHP Sitemap erstellen mit XMLWriter
XMLWriter schreibt Zeile für Zeile und maskiert Sonderzeichen selbst, seine Arbeitsweise erklärt das Tutorial PHP XMLWriter. Zwei kleine Funktionen tragen alles Weitere, auch Index und gzip:
<?php
const NS = 'http://www.sitemaps.org/'
. 'schemas/sitemap/0.9';
const BASIS = 'https://www.example.de/artikel/';
function sitemap_beginnen(string $datei): XMLWriter
{
$w = new XMLWriter();
$w->openUri($datei);
$w->startDocument('1.0', 'UTF-8');
$w->setIndent(true);
$w->startElement('urlset');
$w->writeAttribute('xmlns', NS);
return $w;
}
function url_eintrag(XMLWriter $w, array $z): void
{
$tz = new DateTimeZone('Europe/Berlin');
$d = new DateTimeImmutable(
$z['geaendert_am'],
$tz
);
$url = BASIS . $z['slug'] . '.html';
$w->startElement('url');
$w->writeElement('loc', $url);
$w->writeElement('lastmod', $d->format(DATE_W3C));
$w->endElement();
}
Die Abfrage läuft ungepuffert mit MYSQLI_USE_RESULT, PHP holt die Zeilen also einzeln vom Server, statt alle 50.000 auf einmal zu laden. $db ist eine offene mysqli-Verbindung:
<?php
$res = $db->query(
'SELECT slug, geaendert_am FROM artikel'
. ' WHERE online = 1 ORDER BY id',
MYSQLI_USE_RESULT
);
$w = sitemap_beginnen(__DIR__ . '/sitemap.xml');
$n = 0;
while ($z = $res->fetch_assoc()) {
url_eintrag($w, $z);
if (++$n % 1000 === 0) {
$w->flush();
}
}
$w->endDocument();
$w->flush();
unset($w);
echo "$n URLs geschrieben";
/* 50000 URLs geschrieben */
Das flush() alle 1000 Einträge leert den Puffer, endDocument() schließt das offene urlset. So lässt sich die PHP-Sitemap erstellen, ohne dass der Speicher mit dem Bestand wächst: 50.000 Einträge dauerten 145 bis 300 ms unter Linux und 190 bis 265 ms unter Windows, bei weniger als einem halben Megabyte PHP-Speicher. Die Datei wog 7,0 MB, gut 140 Bytes je Eintrag, und bestand die Prüfung gegen das Schema.
lastmod: das echte Änderungsdatum im W3C-Format
Der Wert in lastmod soll die letzte wesentliche Änderung der Seite wiedergeben. Er gehört in eine eigene Spalte, die beim Speichern eines Artikels gesetzt wird. Die Dateizeit des Skripts oder ein NOW() beim Erzeugen sind falsch, weil dann jede Seite bei jedem Lauf als geändert gilt. Warum das Inhaltsdatum auch für den Header Last-Modified die bessere Quelle ist, zeigt das Tutorial zu getlastmod(). Das Format ist W3C-Datetime, und die Zeitzone muss ausdrücklich dabeistehen:
<?php
$tz = new DateTimeZone('Europe/Berlin');
$wert = '2026-10-24 13:24:00';
$a = new DateTimeImmutable($wert, $tz);
echo $a->format(DATE_W3C), "\n";
/* 2026-10-24T13:24:00+02:00 */
echo date('c', strtotime($wert)), "\n";
/* Windows, Zeitzone UTC: ...T13:24:00+00:00
Linux, Europe/Berlin: ...T13:24:00+02:00 */
$b = new DateTimeImmutable('2026-12-01 09:00', $tz);
echo $b->format(DATE_W3C);
/* 2026-12-01T09:00:00+01:00 */
Auf dem Windows-Rechner stand date.timezone auf UTC, und der Weg über date() lag zwei Stunden daneben, ohne jede Warnung. Mit DateTimeImmutable und fester Zeitzone kam auf beiden Systemen derselbe Wert heraus, im Winter mit +01:00. Erlaubt ist auch das reine Datum wie 2026-10-24. Die alte Konstante DATE_ISO8601 schreibt den Zeitversatz dagegen ohne Doppelpunkt, etwa +0200, und fiel bei der Schema-Prüfung durch.
Das & in Adressen und Umlaute im Pfad
Adressen mit Parametern enthalten oft ein kaufmännisches Und, das in XML als & stehen muss. Wer eine PHP-Sitemap erstellen will und dazu Zeichenketten aneinanderhängt, hat ab dem ersten & eine ungültige Datei. Der Parser meldete im Test EntityRef: expecting ';'. XMLWriter maskiert selbst, bei DOMDocument kommt es auf die Methode an:
<?php
$url = 'https://www.example.de/liste.php'
. '?seite=2&sort=neu';
$w = new XMLWriter();
$w->openMemory();
$w->writeElement('loc', $url);
echo $w->outputMemory(), "\n";
/* <loc>...?seite=2&sort=neu</loc> */
$dom = new DOMDocument('1.0', 'UTF-8');
$loc = @$dom->createElement('loc', $url);
echo $dom->saveXML($loc), "\n";
/* <loc/> */
$loc = $dom->createElement('loc');
$loc->textContent = $url;
echo $dom->saveXML($loc);
/* <loc>...?seite=2&sort=neu</loc> */
createElement() mit einem Wert als zweitem Argument meldete Warning: DOMDocument::createElement(): unterminated entity reference und erzeugte ein leeres Element. Wer Warnungen unterdrückt, wie hier zur Demonstration mit @, hat eine Sitemap voller leerer Adressen. textContent und createTextNode() maskieren richtig. Wer bei verketteten Zeichenketten bleibt, maskiert jeden Wert mit htmlspecialchars($url, ENT_XML1 | ENT_QUOTES, 'UTF-8'), auch das ergab &.
Umlaute im Pfad sind eine zweite Ebene. Das Protokoll verlangt URL-kodierte Adressen, kodiert wird jeder Pfadteil für sich, sonst trifft es auch die Schrägstriche:
<?php
$pfad = 'ratgeber/größe-maße';
$teile = explode('/', $pfad);
echo implode('/', array_map('rawurlencode', $teile));
/* ratgeber/gr%C3%B6%C3%9Fe-ma%C3%9Fe */
XMLWriter oder DOMDocument: Zeit und Speicher bei 50.000 URLs
Viele Beispiele im Netz bauen die Sitemap als DOM-Baum. Gemessen wurden Zeit, PHP-Speicher laut memory_get_peak_usage() und der Zuwachs des Prozesses:
| Weg | Windows | Linux |
| XMLWriter, Zeile für Zeile | 190 bis 265 ms, PHP 0,5 MiB, Prozess +0,9 MiB | 145 bis 300 ms, PHP 0,5 MiB, Prozess +0,5 MiB |
| DOMDocument, Zeilen ungepuffert | 250 bis 405 ms, PHP 0,5 MiB, Prozess +58 MiB | 205 bis 295 ms, PHP 0,5 MiB, Prozess +60 MiB |
DOMDocument mit fetch_all() | 245 bis 265 ms, PHP 27,8 MiB, Prozess +85 MiB | 225 bis 395 ms, PHP 27,8 MiB, Prozess +87 MiB |
Der DOM-Baum belegte je nach Aufbau 40 bis 60 MiB, die memory_get_peak_usage() nicht anzeigt, weil libxml2 seinen Speicher am Speichermanager von PHP vorbei anfordert. Mit memory_limit=16M lief die ungepufferte DOM-Variante deshalb durch, die mit fetch_all() brach mit Allowed memory size of 16777216 bytes exhausted ab, und zwar am Array. Das Limit schützt also nicht vor einem großen DOM.
Mehr als 50.000 URLs: PHP-Sitemap erstellen mit Index
Eine Sitemap darf höchstens 50.000 URLs enthalten und entpackt nicht größer als 50 MB sein, genauer 52.428.800 Bytes. Bei 140 Bytes je Eintrag wiegen 50.000 Adressen etwa 7 MB, die Größengrenze greift erst ab rund 1049 Bytes je Eintrag. Wird der Bestand größer, verteilt das Skript die Adressen auf Teildateien und schreibt eine Sitemap-Indexdatei, die auf sie verweist:
<?php
const PRO_DATEI = 50000;
$res = $db->query(
'SELECT slug, geaendert_am FROM artikel'
. ' WHERE online = 1 ORDER BY id',
MYSQLI_USE_RESULT
);
$teile = [];
$n = 0;
while ($z = $res->fetch_assoc()) {
if ($n % PRO_DATEI === 0) {
if (isset($w)) {
$w->endDocument();
unset($w);
}
$nr = count($teile) + 1;
$w = sitemap_beginnen(
__DIR__ . "/sitemap-$nr.xml"
);
}
url_eintrag($w, $z);
$teile[$nr] = max(
$teile[$nr] ?? '',
$z['geaendert_am']
);
$n++;
}
$w->endDocument();
unset($w);
$teile merkt sich je Teildatei das jüngste Änderungsdatum. Daraus entsteht der Index mit sitemapindex statt urlset:
<?php
$tz = new DateTimeZone('Europe/Berlin');
$i = new XMLWriter();
$i->openUri(__DIR__ . '/sitemap-index.xml');
$i->startDocument('1.0', 'UTF-8');
$i->setIndent(true);
$i->startElement('sitemapindex');
$i->writeAttribute('xmlns', NS);
foreach ($teile as $nr => $neuestes) {
$d = new DateTimeImmutable($neuestes, $tz);
$loc = "https://www.example.de/sitemap-$nr.xml";
$i->startElement('sitemap');
$i->writeElement('loc', $loc);
$i->writeElement('lastmod', $d->format(DATE_W3C));
$i->endElement();
}
$i->endDocument();
echo count($teile), ' Teildateien';
Für den Test stand PRO_DATEI auf 20.000. Aus 50.000 Einträgen wurden drei Teildateien mit 2,8, 2,8 und 1,4 MB und ein Index, der die Prüfung gegen siteindex.xsd bestand. Ein Index darf bis zu 50.000 Sitemaps aufführen, alle auf derselben Website und im Verzeichnis des Index oder darunter.
gzip: die Datei komprimiert schreiben
Wer die PHP-Sitemap erstellen und gleich komprimieren will, schreibt über den Stream-Wrapper compress.zlib://. Die 50-MB-Grenze gilt dann für den entpackten Inhalt, die Funktionen von oben bleiben unverändert:
<?php
$res = $db->query(
'SELECT slug, geaendert_am FROM artikel'
. ' WHERE online = 1 ORDER BY id',
MYSQLI_USE_RESULT
);
$gz = __DIR__ . '/sitemap.xml.gz';
$w = sitemap_beginnen('compress.zlib://' . $gz);
while ($z = $res->fetch_assoc()) {
url_eintrag($w, $z);
}
$w->endDocument();
$w->flush();
$test = @gzdecode(file_get_contents($gz));
var_dump($test === false);
/* bool(true), die Datei ist noch unvollständig */
unset($w);
echo strlen(gzdecode(file_get_contents($gz)));
/* 7006441 */
Nach endDocument() und flush() fehlte noch der gzip-Abschluss, und gzdecode() scheiterte mit data error. Erst nach unset() stand die Datei vollständig auf der Platte. Wer sie im selben Skript prüft, umbenennt oder hochlädt, gibt das Objekt vorher frei. Mit den Testdaten waren es komprimiert 822 KB, wie stark gzip packt, hängt aber an den Adressen. Wie sich die Kompressionsstufen auf Größe und Zeit auswirken, zeigt das Tutorial zu gzencode().
PHP-Sitemap erstellen per Cron statt bei jedem Aufruf
Bei 150 bis 400 ms je Durchlauf lohnt der Neubau bei jedem Abruf nicht, die Datei ändert sich ja nur mit den Artikeln. Sinnvoller ist es, die PHP-Sitemap erstellen zu lassen, wenn wenig los ist, und sie danach statisch auszuliefern. Damit nie eine halbe Datei ausgeliefert wird, entsteht sie unter einem temporären Namen:
<?php
$ziel = __DIR__ . '/sitemap.xml';
$tmp = $ziel . '.tmp';
$res = $db->query(
'SELECT slug, geaendert_am FROM artikel'
. ' WHERE online = 1 ORDER BY id',
MYSQLI_USE_RESULT
);
$w = sitemap_beginnen($tmp);
while ($z = $res->fetch_assoc()) {
url_eintrag($w, $z);
}
$w->endDocument();
unset($w);
if (!rename($tmp, $ziel)) {
exit("sitemap.xml nicht ersetzt\n");
}
echo "sitemap.xml ersetzt\n";
Unter Linux ersetzte rename() die Datei auch, während ein anderer Prozess sie las, unter Windows scheiterte es dann mit Zugriff verweigert (code: 5). Den Rückgabewert zu prüfen, schadet also nicht. Gestartet wird das Skript nachts per Crontab, wie im Tutorial PHP Cronjobs einrichten beschrieben:
15 3 * * * php /var/www/cron/sitemap.php
Kleine Seiten können die Sitemap auch dynamisch ausliefern. Eine sitemap.php sendet header('Content-Type: application/xml; charset=utf-8'), und eine Rewrite-Regel macht sie unter /sitemap.xml erreichbar. Ohne Zwischenspeicher fragt dann aber jeder Crawler-Besuch die Datenbank ab.
robots.txt, Google Search Console: einreichen und prüfen
Danach müssen Suchmaschinen die Datei noch finden. Dafür gehört eine Zeile mit der absoluten Adresse an beliebiger Stelle in die robots.txt. Liegt die Datei in einem Unterordner, gilt sie ohne Search Console nur für Adressen unterhalb dieses Ordners:
Sitemap: https://www.example.de/sitemap.xml
Zusätzlich lässt sich die Sitemap im Bericht „Sitemaps“ der Google Search Console einreichen, bei Bing in den Bing Webmaster Tools. Den früher üblichen Ping an Google gibt es nicht mehr, Google hat den Endpunkt im Juni 2023 abgekündigt und inzwischen abgeschaltet. Vor dem Einreichen hilft eine Prüfung gegen das Schema sitemap.xsd von sitemaps.org:
<?php
libxml_use_internal_errors(true);
$d = new DOMDocument();
$d->load(__DIR__ . '/sitemap.xml');
$ok = $d->schemaValidate(__DIR__ . '/sitemap.xsd');
var_dump($ok);
/* bool(true) */
foreach (libxml_get_errors() as $fehler) {
echo trim($fehler->message), "\n";
}
Die Prüfung fand im Test die typischen Fehler: ein deutsches Datum wie 24.10.2026, ein Leerzeichen statt T vor der Uhrzeit, einen fehlenden Namensraum und relative Adressen. Eine Uhrzeit ohne Zeitzone ließ das Schema dagegen durch, obwohl das W3C-Format sie verlangt. Bestanden heißt also formal in Ordnung, nicht zwingend richtig.
Fazit
Eine eigene PHP-Sitemap erstellen ist weniger Arbeit als die Fehlersuche danach. XMLWriter schreibt direkt aus einer ungepufferten Abfrage, maskiert das & selbst und braucht ein halbes Megabyte, wo ein DOM-Baum Dutzende MiB am memory_limit vorbei belegt. lastmod kommt aus der Spalte mit der letzten inhaltlichen Änderung, mit DATE_W3C und fester Zeitzone. changefreq und priority kann man sich sparen.
Ab 50.000 Adressen übernimmt ein Sitemap-Index, gzip spart Bandbreite, sofern das Objekt vor dem Weiterverarbeiten freigegeben ist. Wer eine PHP-Sitemap erstellen will, tut das nachts per Cron über eine temporäre Datei und macht sie über robots.txt und Search Console bekannt.