PHP でディレクトリを削除しようと rmdir() を呼んだのに、「Directory not empty」という警告が出て消えない、ということがあります。rmdir() は、空のディレクトリしか削除できない関数です。中身ごと消すには、中のファイルとディレクトリを先に消していく必要があります。削除は間違えると取り返しがつかない処理なので、安全策もあわせて考えます。

rmdir は空のディレクトリだけを消す

<?php
// 中にファイルがあると、警告が出て false が返る
var_dump(rmdir(__DIR__ . '/cache')); // bool(false)

よく見かけるのが、glob() で中のファイルを消してから rmdir() する書き方です。

<?php
// 見えるファイルだけを消してから rmdir する (うまくいかないことがある)
foreach (glob($dir . '/*') as $file) {
    unlink($file);
}
rmdir($dir);

この書き方には、つまずく点が 2 つあります。1 つは、glob() の * が、.htaccess のように . で始まる名前 (隠しファイル) に一致しないことです。隠しファイルが残るので、最後の rmdir() が失敗します。もう 1 つは、中にサブディレクトリがあると、unlink() では消せないことです。

中身ごと消す: 奥から順に消していく

中身を漏れなくたどるには、RecursiveDirectoryIterator と RecursiveIteratorIterator を組み合わせます。

<?php
// ディレクトリを中身ごと削除する
function remove_tree(string $dir): void
{
    $items = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
        RecursiveIteratorIterator::CHILD_FIRST
    );
    foreach ($items as $item) {
        $path = $item->getPathname();
        if ($item->isLink()) {
            // シンボリックリンクは、リンク先をたどらずにリンクだけを消す
            // (Windows では、ディレクトリへのリンクは rmdir() で消す)
            $ok = (PHP_OS_FAMILY === 'Windows' && $item->isDir()) ? rmdir($path) : unlink($path);
        } elseif ($item->isDir()) {
            $ok = rmdir($path);
        } else {
            $ok = unlink($path);
        }
        if (!$ok) {
            throw new RuntimeException('削除できませんでした: ' . $path);
        }
    }
    if (!rmdir($dir)) {
        throw new RuntimeException('削除できませんでした: ' . $dir);
    }
}
  • RecursiveDirectoryIterator は、ディレクトリの中をサブディレクトリまで含めてたどります。隠しファイルも含まれます。FilesystemIterator::SKIP_DOTS で、. (自分自身) と .. (親) を飛ばします
  • RecursiveIteratorIterator::CHILD_FIRST を指定すると、ディレクトリの中身を先に、ディレクトリ自身を後に返します。中身を消してからディレクトリを消す順番になります
  • RecursiveDirectoryIterator は、既定ではシンボリックリンクの先のディレクトリへは入りません。ただし isDir() は、リンク先がディレクトリなら true を返すので、isLink() で先に見分けて、リンクそのものだけを消します。Windows では、ディレクトリへのシンボリックリンクは unlink() ではなく rmdir() で消す決まりです
  • 1 つでも消せなければ例外を投げ、途中で止めます。失敗を無視して進めると、何が残ったのかわからなくなります

消す場所を確かめる

中身ごと消す処理でいちばん怖いのは、消す場所を間違えることです。たとえば remove_tree($baseDir . '/' . $name) で $name が空だと、$baseDir が丸ごと消えます。次のような確認を入れておきます。

<?php
// $base の中にあるディレクトリだけを、中身ごと削除する
function remove_under(string $base, string $target): void
{
    if (is_link($target)) {
        throw new InvalidArgumentException('シンボリックリンクは対象にしません: ' . $target);
    }
    // ../ やリンクを解決した、実際の絶対パスで比べる
    $realBase = realpath($base);
    $realTarget = realpath($target);
    // 存在しない、基準の外にある、基準そのもの、のどれかなら消さない (str_starts_with() は PHP 8.0 以降)
    if ($realBase === false || $realTarget === false
        || !str_starts_with($realTarget, $realBase . DIRECTORY_SEPARATOR)) {
        throw new InvalidArgumentException('削除できない場所です: ' . $target);
    }
    remove_tree($realTarget);
}
  • 消してよい場所 (基準のディレクトリ) を決め、その中だけを消す。一時的なファイルなら、専用のディレクトリを決めておくと範囲がはっきりする
  • 基準のディレクトリそのものや、空のパスは消さない
  • 利用者の入力を、そのままパスに使わない
  • 作ったばかりの処理は、まず削除せずに対象の一覧を表示して、思ったとおりかを確かめる

exec() で rm -rf を呼ぶ方法もありますが、パスの扱いを誤ったときの被害が大きいので避けます。部品を使ってよいなら、Symfony の Filesystem コンポーネントの remove() のように、確かめられた実装を使う手もあります。

Windows で消せないとき

Linux では動くのに、Windows では消せないことがあります。よくある原因は、ファイルが開かれたままになっていることです。Linux では、開いているファイルも削除できます (名前が消え、閉じたときに中身が消えます)。Windows では、事情が少し違います。

  • エディターやエクスプローラーのプレビュー、ウイルス対策のソフトウェアなど、ほかのプログラムが開いていると、削除できないことがある
  • PHP 自身が開いているファイルは、PHP 7.3 からは開いたままでも unlink() できる。ただし、閉じられるまでファイルが残り、親のディレクトリの rmdir() が失敗することがある。fopen() したら fclose() し、SplFileObject などのオブジェクトは使い終わったら unset() する
  • 読み取り専用の属性が付いていると、unlink() が失敗することがある。chmod($path, 0666) で書き込みを許可してから消す

ほかのプログラムが一時的に開いているだけのこともあるので、少し待ってからやり直すと消えることもあります。

まとめ

  • rmdir() は空のディレクトリしか消せない。glob() の * は隠しファイルに一致しないので、取り残しが出る
  • 中身ごと消すには、RecursiveDirectoryIterator と CHILD_FIRST で、奥から順に消す
  • シンボリックリンクはたどらず、リンクだけを消す
  • realpath() で実際のパスにしてから、消してよい場所の中かを確かめる
  • Windows では、開いたままのファイルや読み取り専用のファイルは消せない

参考