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 では、開いたままのファイルや読み取り専用のファイルは消せない