WordPress aufräumen: Verwaiste Bilddateien aufspüren und in eine Quarantäne verschieben, um sie zu löschen

Wie werde ich bloß die alten Bilder los? Jeder, der sich etwas länger mit WordPress beschäftigt, weiß, im Ordner wp_uploads sammeln sich mit der Zeit jede Menge Bilddateien an. Nach Theme-Wechseln, Plugin-Experimenten oder dem Löschen von Beiträgen bleiben im Upload-Verzeichnis zahlreiche Bilddateien zurück, die von WordPress gar nicht mehr verwendet werden. 

Gerade bei älteren Installationen können da unter Umständen mehr als ein Gigabyte unnötig belegter Speicherplatz zusammenkommen. Um diese Dateien aufzuspüren, habe ich ein kleines PHP-Skript entwickelt, das direkt auf der Kommandozeile funktioniert. Dabei greift es auf die WordPress-API zurück und vergleicht den tatsächlichen Inhalt der Mediathek mit den Dateien im Upload-Verzeichnis.

Was macht das Skript?

Das Skript arbeitet in vier Schritten:

1. WordPress initialisieren

Zunächst überprüft es, ob das Skript auf der Kommandozeile (CLI) gestartet wurde und sich im Root-Verzeichnis der WordPress-Installation befindet. Anschließend lädt es über `wp-load.php` die komplette WordPress-Umgebung. Dadurch können sämtliche WordPress-Funktionen genutzt werden, ohne dass Änderungen an der Website oder im Backend erforderlich sind.

2. Alle bekannten Bilddateien ermitteln

Im nächsten Schritt liest das Skript sämtliche Bildanhänge aus der WordPress-Datenbank aus. Dabei werden nicht nur die Originalbilder berücksichtigt, sondern auch alle von WordPress erzeugten Bildgrößen (Thumbnails), die in den Metadaten eines Bildes hinterlegt sind. So entsteht eine vollständige Liste aller Bilddateien, die WordPress tatsächlich kennt und verwendet.

3. Upload-Verzeichnis scannen

Anschließend durchsucht das Skript rekursiv das komplette Upload-Verzeichnis. Berücksichtigt werden ausschließlich Bilddateien mit den Endungen:

  • JPG / JPEG
  • PNG
  • GIF
  • WebP
  • AVIF

Plugin-Dateien, Fonts, Logdateien oder Cache-Dateien bleiben außen vor, um die Installation nicht zu gefährden.

4. Vergleich durchführen

Jetzt werden beide Listen miteinander verglichen. Alle Bilddateien, die zwar im Upload-Verzeichnis vorhanden sind, aber in der WordPress-Datenbank nicht mehr referenziert werden, gelten als potenziell verwaist.

Am Ende gibt das Skript eine Zusammenfassung aus:

  • Anzahl der Bildanhänge in WordPress
  • Anzahl der bekannten Bilddateien
  • Anzahl aller Bilddateien im Upload-Verzeichnis
  • Anzahl der nicht referenzierten Dateien
  • möglicher Speichergewinn

Drei Betriebsmodi

Das Skript kennt drei verschiedene Betriebsarten.

Analyse

php media-cleanup-analyse.php
php media-cleanup-analyse.php

Mit diesem Aufruf erfolgt ausschließlich eine Analyse. Es werden keinerlei Dateien verändert. Aber das ermöglicht Euch zu beurteilen, wie viel Datenmüll Ihr in Eurer WordPress-Instanz mitschleppt.

Dry-Run

php media-cleanup-analyse.php --quarantine --dry-run
php media-cleanup-analyse.php --quarantine --dry-run

Jetzt zeigt das Skript zusätzlich jede einzelne gefundene Datei an, die verschoben würde. Tatsächlich verändert wird jedoch nichts. Bitte prüft die Liste vor dem nächsten Schritt auf Plausibilität. Eine Gefahr ist beispielsweise, wenn ihr in der Vergangenheit Bilder direkt per html-Tag in Eure Beiträge eingefügt habt. Das kann das Skript nicht erkennen.

Quarantäne

php media-cleanup-analyse.php --quarantine
php media-cleanup-analyse.php --quarantine

Erst in diesem Modus werden die gefundenen Dateien verschoben.

Wichtig dabei: Die Dateien werden nicht gelöscht, sondern in einen automatisch angelegten Quarantäne-Ordner innerhalb des Upload-Verzeichnisses verschoben. Dadurch können die als überflüssig identifizierten Bilder bei Bedarf jederzeit wieder zurückkopiert werden.

Warum Quarantäne statt Löschen?

Gerade bei älteren WordPress-Installationen ist nie vollständig auszuschließen, dass Plugins oder Themes auf Dateien zugreifen, die nicht mehr in der Datenbank registriert sind.

Aus diesem Grund löscht das Skript bewusst nichts endgültig.

Die Quarantäne bietet die Möglichkeit, die Website zunächst einige Tage oder Wochen zu beobachten. Erst wenn sicher ist, dass keine Bilder fehlen, kann der Quarantäne-Ordner endgültig entfernt werden.

Grenzen des Skripts

Das Skript erkennt ausschließlich Bilddateien, die nicht mehr in der WordPress-Datenbank referenziert werden.

Es erkennt dagegen nicht:

  • Bilder, die zwar in der Mediathek vorhanden sind, aber auf keiner Seite mehr verwendet werden.
  • Alte Bildgrößen, die zwar in den Metadaten eingetragen sind, von einem heutigen Theme aber nicht mehr benötigt werden. Gerade da steckt zusätzliches Potenzial. Aber mir fehlte bisher die Zeit, um mich damit zu beschäftigen.
  • Manuell hochgeladene Bilder außerhalb der Mediathek.

Warnhinweis

Der Einsatz dieses Skripts erfolgt ausdrücklich auf eigene Gefahr.

Obwohl die Dateien nicht gelöscht, sondern lediglich in einen Quarantäne-Ordner verschoben werden, sollte das Skript ausschließlich auf einer vollständig gesicherten WordPress-Installation eingesetzt werden. Vor dem ersten Einsatz empfiehlt sich unbedingt ein aktuelles Backup von Dateien und Datenbank.

Bei einer größeren WordPress-Installation schaufelte ich mit dem Skript immerhin 1,71 GB frei.

Ich empfehle außerdem, zunächst immer die Analyse und anschließend den Dry-Run auszuführen. Erst wenn die Ergebnisse plausibel erscheinen, sollte der Quarantäne-Modus verwendet werden.

Denn trotz sorgfältiger Entwicklung kann nicht ausgeschlossen werden, dass individuelle Themes oder Plugins Dateien verwenden, die von WordPress selbst nicht mehr referenziert werden. In solchen Fällen ist eine manuelle Prüfung sinnvoll.

Code des PHP-Skripts:

<?php
/**
 * WordPress Media Cleanup Analyse
 *
 * @author     Tom Schwede <info@autonatives.de>
 * @copyright  2026 Tom Schwede
 * @license    https://opensource.org/licenses/MIT MIT License
 */

/**
 *
 * Usage:
 *   php media-cleanup-analyse.php
 *   php media-cleanup-analyse.php --quarantine --dry-run
 *   php media-cleanup-analyse.php --quarantine
 */

 /*
 The MIT License (MIT)

 Copyright (c) 2026 Dein Name

 Permission is hereby granted, free of charge, to any person obtaining a copy
 of this software and associated documentation files (the "Software"), to deal
 in the Software without restriction, including without limitation the rights
 to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 copies of the Software, and to permit persons to whom the Software is
 furnished to do so, subject to the following conditions:

 The above copyright notice and this permission notice shall be included in all
 copies or substantial portions of the Software.

 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 SOFTWARE.
 */

 /*
  HAFTUNGSAUSSCHLUSS:
  Diese Software wird vom Urheberrechtsinhaber "wie besehen" (as is) zur Verfügung
  gestellt. Jegliche ausdrückliche oder implizite Haftung, einschließlich, aber
  nicht beschränkt auf die implizite Haftung für Marktgängigkeit und Eignung für
  einen bestimmten Zweck, wird ausgeschlossen.

  In keinem Fall haftet der Urheber für direkte, indirekte, zufällige, besondere,
  beispielhafte oder Folgeschäden (einschließlich, aber nicht beschränkt auf die
  Beschaffung von Ersatzgütern oder -dienstleistungen, Datenverlust, entgangenen
  Gewinn oder Betriebsunterbrechung), unabhängig von der Ursache und der Haftungstheorie
  (ob aus Vertrag, Gefährdungshaftung oder unerlaubter Handlung, einschließlich
  Fahrlässigkeit), die in irgendeiner Weise aus der Nutzung dieser Software entstehen,
  selbst wenn auf die Möglichkeit solcher Schäden hingewiesen wurde.
*/

if (php_sapi_name() !== 'cli') {
    die("Dieses Skript darf nur auf der Kommandozeile laufen.\n");
}

$wpLoad = __DIR__ . '/wp-load.php';

if (!file_exists($wpLoad)) {
    die("Fehler: wp-load.php nicht gefunden. Bitte im WordPress-Root ausführen.\n");
}

require_once $wpLoad;

$uploads = wp_get_upload_dir();
$uploadsBaseDir = rtrim($uploads['basedir'], '/');

if (!is_dir($uploadsBaseDir)) {
    die("Fehler: Upload-Verzeichnis nicht gefunden: {$uploadsBaseDir}\n");
}

$doQuarantine = in_array('--quarantine', $argv, true);
$dryRun       = in_array('--dry-run', $argv, true);

$quarantineDir = $uploadsBaseDir . '/_media_cleanup_quarantine_' . date('Ymd_His');

echo "WordPress Media Cleanup Analyse\n";
echo "Uploads: {$uploadsBaseDir}\n";
echo $dryRun ? "Modus: DRY-RUN\n" : "Modus: ECHT\n";
echo "\n";

/**
 * 1. Dateien ermitteln, die WordPress laut Datenbank kennt.
 */
$knownFiles = [];

$attachments = get_posts([
    'post_type'      => 'attachment',
    'post_mime_type' => 'image',
    'post_status'    => 'any',
    'numberposts'    => -1,
    'fields'         => 'ids',
]);

foreach ($attachments as $attachmentId) {
    $file = get_post_meta($attachmentId, '_wp_attached_file', true);

    if ($file) {
        $knownFiles[$file] = true;
    }

    $meta = wp_get_attachment_metadata($attachmentId);

    if (!empty($meta['file'])) {
        $knownFiles[$meta['file']] = true;
        $dir = dirname($meta['file']);

        if (!empty($meta['sizes']) && is_array($meta['sizes'])) {
            foreach ($meta['sizes'] as $size) {
                if (!empty($size['file'])) {
                    $knownFiles[$dir . '/' . $size['file']] = true;
                }
            }
        }
    }
}

/**
 * 2. Alle Dateien im Upload-Verzeichnis scannen.
 */
$allFiles = [];

$iterator = new RecursiveIteratorIterator(
    new RecursiveDirectoryIterator($uploadsBaseDir, FilesystemIterator::SKIP_DOTS)
);

$allowedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif'];

foreach ($iterator as $fileInfo) {
    if (!$fileInfo->isFile()) {
        continue;
    }

    $absolutePath = $fileInfo->getPathname();

    if (strpos($absolutePath, '/_media_cleanup_quarantine_') !== false) {
        continue;
    }

    $extension = strtolower(pathinfo($absolutePath, PATHINFO_EXTENSION));

    if (!in_array($extension, $allowedExtensions, true)) {
        continue;
    }

    $relativePath = ltrim(str_replace($uploadsBaseDir, '', $absolutePath), '/');

    $allFiles[$relativePath] = [
        'absolute' => $absolutePath,
        'size'     => $fileInfo->getSize(),
    ];
}

/**
 * 3. Differenz bilden: Datei existiert, ist aber nicht in WordPress bekannt.
 */
$unusedFiles = [];
$totalUnusedSize = 0;

foreach ($allFiles as $relativePath => $info) {
    if (!isset($knownFiles[$relativePath])) {
        $unusedFiles[$relativePath] = $info;
        $totalUnusedSize += $info['size'];
    }
}

function human_filesize($bytes): string
{
    $units = ['B', 'KB', 'MB', 'GB', 'TB'];
    $i = 0;

    while ($bytes >= 1024 && $i < count($units) - 1) {
        $bytes /= 1024;
        $i++;
    }

    return round($bytes, 2) . ' ' . $units[$i];
}

echo "Anhänge in WordPress:       " . count($attachments) . "\n";
echo "Bekannte Bilddateien:       " . count($knownFiles) . "\n";
echo "Dateien im Upload-Ordner:   " . count($allFiles) . "\n";
echo "Nicht referenzierte Dateien:" . count($unusedFiles) . "\n";
echo "Möglicher Platzgewinn:      " . human_filesize($totalUnusedSize) . "\n";
echo "\n";

if (!$doQuarantine) {
    echo "Es wurde nichts verschoben.\n";
    echo "Zum Testen:\n";
    echo "php media-cleanup-analyse.php --quarantine --dry-run\n";
    exit;
}

/**
 * 4. Dateien in Quarantäne verschieben.
 */
if (!$dryRun && !is_dir($quarantineDir)) {
    mkdir($quarantineDir, 0755, true);
}

$moved = 0;
$failed = 0;

foreach ($unusedFiles as $relativePath => $info) {
    $source = $info['absolute'];
    $target = $quarantineDir . '/' . $relativePath;

    echo ($dryRun ? "[DRY-RUN] " : "") . $relativePath . " (" . human_filesize($info['size']) . ")\n";

    if ($dryRun) {
        continue;
    }

    $targetDir = dirname($target);

    if (!is_dir($targetDir)) {
        mkdir($targetDir, 0755, true);
    }

    if (@rename($source, $target)) {
        $moved++;
    } else {
        $failed++;
    }
}

echo "\n";
echo $dryRun ? "Dry-Run abgeschlossen.\n" : "Quarantäne abgeschlossen.\n";

if (!$dryRun) {
    echo "Verschoben: {$moved}\n";
    echo "Fehler:     {$failed}\n";
    echo "Ordner:     {$quarantineDir}\n";
}

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert

Mit dem Absenden des Kommentars erklärst Du Dich mit der Speicherung und Verarbeitung Deiner Daten durch diese Website einverstanden. Weitere Informationen findest Du in unserer Datenschutzerklärung.