Ein Kundenportal legt für jeden Auftrag einen Ordner mit Druckdaten an. Nach dem Abschluss soll er verschwinden, doch der Aufruf meldet nur eine Warnung und tut nichts. Der Ordner ist nicht leer, und genau daran scheitert der erste Versuch fast immer.
Im Bild verschwinden zuerst die Dateien tief im Baum, der äußere Ordner zuletzt. Diese Reihenfolge ist der ganze Trick.
Was PHP rmdir() macht und was nicht
PHP rmdir() entfernt ein Verzeichnis, aber nur dann, wenn es vollständig leer ist. Enthält es auch nur eine versteckte Datei, gibt die Funktion false zurück und meldet eine Warnung. Es gibt keinen Schalter, der das ändert; das Gegenstück zum rekursiven Anlegen fehlt bewusst.
<?php
$ordner = '/var/www/tmp/auftrag-4711';
if (!rmdir($ordner)) {
echo 'Loeschen fehlgeschlagen';
}
/* Warning: rmdir(...): Directory not empty */
Wer Verzeichnisse anlegt, kennt den Unterschied bereits: PHP mkdir() erstellt Verzeichnisse und setzt Berechtigungen und legt mit einem einzigen Schalter ganze Pfade an. Beim Löschen gibt es diesen Schalter nicht, und deshalb braucht jedes Projekt früher oder später eine eigene rekursive Funktion.
Die Signatur im Original
Die Handbuchseite ist kurz, nennt aber die eine Bedingung, an der die Funktion in der Praxis am häufigsten scheitert:
rmdir(string $directory, ?resource $context = null): bool
/* directory - the path of the directory to delete
context - an optional stream context parameter
return - true on success, false on failure
The directory must be empty before using this function. Delete its
contents first, for example with a recursive helper. */
Genau diese Bedingung ist der Grund für den rekursiven Ansatz weiter unten: Ohne ihn scheitert der Aufruf an jedem einzelnen verbliebenen Eintrag.
rmdir() und unlink(): wer löscht was
Bevor der rekursive Teil entsteht, lohnt ein Blick auf die Zuständigkeiten. PHP rmdir() ist ausschließlich für Verzeichnisse zuständig, unlink() ausschließlich für Dateien. Bei symbolischen Verweisen wird es interessant.
| Ziel | rmdir() | unlink() |
| Datei | schlägt fehl | löscht |
| leeres Verzeichnis | löscht | schlägt fehl |
| gefülltes Verzeichnis | schlägt fehl | schlägt fehl |
| Symlink auf Verzeichnis | schlägt unter Linux fehl | löscht den Verweis |
Die letzte Zeile ist der Grund, warum eine rekursive Funktion ohne Symlink-Prüfung frueher oder später Schaden anrichtet. Wer Dateien einzeln entfernt, findet die Details im Tutorial zu Dateien mit PHP löschen.
Ein Verzeichnis mit Inhalt löschen
Die verständliche Variante liest den Ordnerinhalt mit scandir(), arbeitet ihn ab und ruft PHP rmdir() erst ganz am Ende auf. Wichtig sind die beiden Einträge . und .., die jedes Verzeichnis mitliefert. Wer sie nicht herausfiltert, läuft in eine Endlosschleife.
<?php
function ordnerLoeschen(string $pfad): bool
{
if (!is_dir($pfad)) {
return false;
}
foreach (scandir($pfad) as $eintrag) {
if ($eintrag === '.' || $eintrag === '..') {
continue;
}
$voll = $pfad . DIRECTORY_SEPARATOR . $eintrag;
if (is_link($voll)) {
unlink($voll); /* Verweis entfernen, nicht folgen */
} elseif (is_dir($voll)) {
ordnerLoeschen($voll); /* eine Ebene tiefer */
} else {
unlink($voll);
}
}
return rmdir($pfad);
}
Diese Funktion ist gut lesbar und für überschaubare Verzeichnisse völlig ausreichend. Sie hat allerdings eine Schwachstelle: scandir() baut den kompletten Inhalt als Array im Speicher auf. Bei einem Cache-Verzeichnis mit 200.000 Dateien wird das zum Problem.
Die speicherschonende Variante für große Bäume
Die Standardbibliothek von PHP bringt dafür einen Iterator mit, der den Baum durchläuft, ohne ihn vollständig einzulesen. Entscheidend ist die Konstante CHILD_FIRST: sie sorgt dafür, dass Kinder vor ihren Eltern geliefert werden. Ohne sie käme das Verzeichnis vor seinem Inhalt, und PHP rmdir() würde jedes Mal scheitern.
<?php
function baumLoeschen(string $pfad): bool
{
if (!is_dir($pfad)) {
return false;
}
$eintraege = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator(
$pfad,
FilesystemIterator::SKIP_DOTS
),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($eintraege as $eintrag) {
if ($eintrag->isLink() || $eintrag->isFile()) {
unlink($eintrag->getPathname());
} else {
rmdir($eintrag->getPathname());
}
}
return rmdir($pfad);
}
FilesystemIterator::SKIP_DOTS erledigt die Filterung der beiden Sondereinträge gleich mit. Der Speicherbedarf bleibt bei dieser Variante praktisch konstant, unabhängig davon, ob hundert oder hunderttausend Dateien im Baum liegen.
Warum "Permission denied" am falschen Ort steht
Ein Skript läuft lokal und auf dem Server nicht. Die Fehlermeldung nennt den Pfad des Verzeichnisses, und genau dort sucht man dann vergeblich. Der Grund: zum Entfernen eines Verzeichnisses braucht der Prozess Schreibrecht am übergeordneten Verzeichnis, nicht am Verzeichnis selbst. Das Verzeichnis ist ein Eintrag in seinem Elternordner, und dieser Eintrag wird gelöscht.
<?php
$ziel = '/var/www/tmp/auftrag-4711';
if (!is_writable(dirname($ziel))) {
echo 'Kein Schreibrecht im uebergeordneten Verzeichnis';
} elseif (!rmdir($ziel)) {
echo 'Loeschen fehlgeschlagen: ' . (error_get_last()['message'] ?? '');
}
Der Aufruf von error_get_last() ersetzt die verbreitete Unsitte, die Warnung mit einem vorangestellten Klammeraffen zu unterdrücken. Die unterdrückte Warnung verschwindet zwar aus dem Protokoll, das Problem bleibt aber bestehen und fällt erst später auf.
Symlinks nicht verfolgen
Zeigt im Baum ein symbolischer Verweis auf ein anderes Verzeichnis, und steigt die rekursive Funktion dort hinein, räumt sie Daten ab, die gar nicht zum Zielordner gehören. Beide Beispiele oben prüfen deshalb mit is_link(), bevor sie absteigen. Ein Verweis wird mit unlink() entfernt, nicht mit PHP rmdir(), auch wenn er auf ein Verzeichnis zeigt.
<?php
$verweis = '/var/www/tmp/auftrag-4711/vorlagen';
var_dump(is_link($verweis)); // bool(true)
var_dump(is_dir($verweis)); // bool(true), folgt dem Verweis
/* richtig: */
unlink($verweis);
Die beiden ersten Ausgaben zeigen die Falle: is_dir() folgt dem Verweis und meldet ein Verzeichnis. Wer nur darauf prüft, steigt ab. Deshalb steht die Prüfung auf is_link() in der Reihenfolge immer zuerst.
Pfade aus Benutzereingaben absichern
Sobald ein Teil des Pfades aus einem Formular, einer URL oder einer Konfiguration kommt, muss er geprüft werden, bevor er an das Dateisystem geht. Ein ../ reicht, um aus dem vorgesehenen Bereich herauszuführen. Die Absicherung besteht aus zwei Schritten: auflösen und vergleichen.
<?php
$basis = realpath('/var/www/tmp');
$roh = $basis . '/' . ($_POST['ordner'] ?? '');
$ziel = realpath($roh);
if ($ziel === false || !str_starts_with($ziel, $basis . DIRECTORY_SEPARATOR)) {
exit('Ungueltiger Pfad');
}
baumLoeschen($ziel);
realpath() löst .. und symbolische Verweise vollständig auf und liefert false, wenn der Pfad nicht existiert. Erst der Vergleich mit dem Basispfad einschließlich Trennzeichen macht die Prüfung dicht; ohne das Trennzeichen würde auch ein Ordner namens tmp-alt durchgehen.
Die folgende Übersicht fasst die Reihenfolge zusammen, in der ein Eintrag geprüft und behandelt wird.
flowchart TD
A[Pfad pruefen] --> B{is_link}
B -->|ja| C[unlink, nicht absteigen]
B -->|nein| D{is_dir}
D -->|nein| E[unlink]
D -->|ja| F[Inhalt durchlaufen]
F --> G[Kinder zuerst loeschen]
G --> H[rmdir auf Verzeichnis]
Der Ablauf zeigt, warum die Symlink-Prüfung ganz oben steht und nicht erst nach der Verzeichnisprüfung.
Wenn scheinbar nichts passiert
Manchmal meldet ein Skript Erfolg, und der Ordner ist trotzdem noch da, oder umgekehrt. Drei Ursachen decken die meisten Fälle ab.
Erstens der Stat-Cache: PHP merkt sich Informationen über Dateien innerhalb einer Anfrage. Eine Prüfung mit file_exists() direkt nach dem Löschen kann deshalb noch true liefern. clearstatcache() leert den Zwischenspeicher.
<?php
rmdir('/var/www/tmp/auftrag-4711');
var_dump(file_exists('/var/www/tmp/auftrag-4711')); // evtl. bool(true)
clearstatcache();
var_dump(file_exists('/var/www/tmp/auftrag-4711')); // bool(false)
Zweitens der nicht geprüfte Rückgabewert. PHP rmdir() liefert einen Wahrheitswert, und wer ihn ignoriert, erfährt vom Fehlschlag nichts. Drittens offene Handles unter Windows: dort blockiert eine noch geöffnete Datei das Löschen des Verzeichnisses. Unter Linux fällt das nicht auf, weil der Verzeichniseintrag sofort verschwindet. Die Reihenfolge lautet also fclose(), dann unlink(), dann PHP rmdir().
Ein Aufräumhelfer für Tests
In Testfällen wird derselbe temporäre Ordner immer wieder angelegt und entfernt. Eine Hilfsfunktion sollte deshalb mehrfach aufrufbar sein, ohne beim zweiten Mal zu scheitern.
<?php
function aufraeumen(string $pfad): void
{
if (!file_exists($pfad)) {
return; /* schon weg, kein Fehler */
}
baumLoeschen($pfad);
clearstatcache(true, $pfad);
}
Ob ein Pfad überhaupt existiert und was er ist, klärt das Tutorial zu Datei- und Verzeichnisprüfung mit PHP im Detail.
Fazit
PHP rmdir() entfernt genau eine Sache: ein leeres Verzeichnis. Alles darüber hinaus ist Eigenarbeit, und die besteht aus drei Entscheidungen. Die Reihenfolge muss von innen nach außen laufen, symbolische Verweise dürfen nicht verfolgt werden, und Pfade aus fremder Hand gehören mit realpath() geprüft.
Für kleine Ordner reicht die scandir()-Variante, für große Bäume nimmt man den Iterator mit CHILD_FIRST. Und wenn PHP rmdir() trotz allem fehlschlägt, lohnt der Blick auf das übergeordnete Verzeichnis, nicht auf das Ziel selbst.