0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Laravelで「DB未参照の画像」を安全に掃除する Artisan コマンド(--dry-run/--keep 付き)

Posted at

背景

運用していると、DBからは参照されていない古い画像ファイルが storage/app/public/... に溜まりがちです。
本記事では、DBを基準に「未使用ファイルだけ」安全に削除できる Artisan コマンドを作ります。

▪️ 判定は 「ファイル名の最初の _ 以降が一致するか」 に限定(例:12345_home.webp → home.webp で照合)

▪️ --dry-run で削除せず候補だけ確認可

▪️ --keep で消したくないファイル名を追加除外可

▪️ デフォルトで github.png / noImage.jpg / qiita.png / webApp.png / YouTube.png を保護

例:DBに image_path = "home.webp" が入っていれば、実ファイル xxxxx_home.webp は 使用中 とみなされ削除しません。

前提

▪️ Laravel 9 以降を想定

▪️ 画像は public ディスク(= storage/app/public) に保存

▪️ 参照テーブル:collection_images、列:image_path(必要に応じて読み替えてください)

1. コマンドを作成

app/Console/Commands/PruneCollectionImages.php を作成(ファイル名は任意)

PruneCollectionImages.php
<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\{DB, Storage};
use Illuminate\Support\Str;

class PruneCollectionImages extends Command
{
    protected $signature = 'storage:prune-collection-images
        {--dir=collection_images : storage/app/public 配下の対象ディレクトリ}
        {--dry-run : 削除せず候補のみ表示}
        {--keep=* : 削除対象から常に除外するファイル名(basename。複数指定可)}';

    protected $description = 'DB未参照の画像を削除(アンダースコア以降の一致のみ)';

    public function handle()
    {
        $disk = Storage::disk('public');          // storage/app/public
        $dir  = $this->option('dir') ?: 'collection_images';

        if (!$disk->exists($dir)) {
            $this->warn("📂 ディレクトリが見つかりません: storage/app/public/{$dir}");
            return self::SUCCESS;
        }

        // 0) 常に残すファイル(固定)+ オプション --keep
        $alwaysKeep = [
            'github.png',
            'noimage.jpg',  // 大文字小文字差異を吸収するため小文字で扱う
            'qiita.png',
            'webapp.png',
            'youtube.png',
        ];
        $optKeep = array_map('strtolower', (array)$this->option('keep'));
        $keepSet = collect(array_unique(array_merge($alwaysKeep, $optKeep)))->flip();

        // 1) DB側:参照中パスを取得(null/空は除去)
        $referenced = DB::table('collection_images')->pluck('image_path')->filter();

        // 2) 「_ の後ろ」一致用セット(basename の最初の '_' 以降)
        $suffixSet = $referenced
            ->map(function ($p) {
                $base = basename(ltrim((string)$p, '/'));
                return Str::contains($base, '_') ? Str::after($base, '_') : $base; // '_' が無ければ全体を使う
            })
            ->filter()
            ->unique()
            ->flip();

        // 3) 実ファイルを走査し、未参照だけ抽出(保護対象はスキップ)
        $files = collect($disk->files($dir));
        $cands = [];

        foreach ($files as $path) {
            $base = basename($path);
            if (Str::startsWith($base, '.')) continue;          // .DS_Store 等は無視
            if ($keepSet->has(strtolower($base))) continue;     // 保護対象

            // 「_ の後ろ」一致で参照判定
            $after      = Str::contains($base, '_') ? Str::after($base, '_') : $base;
            $matchAfter = $suffixSet->has($after);

            if (!$matchAfter) {
                $cands[] = $path; // 未参照(削除候補)
            }
        }

        // 4) ドライラン
        if ($this->option('dry-run')) {
            $this->info('🧪 ドライラン: 削除候補一覧');
            foreach ($cands as $p) $this->line(" - {$p}");
            $this->info('合計: ' . count($cands) . ' ファイル');
            return self::SUCCESS;
        }

        // 5) 削除実行
        foreach ($cands as $p) {
            $disk->delete($p);
        }

        $this->info('🗑️ 削除完了: ' . count($cands) . ' ファイル');
        return self::SUCCESS;
    }
}

コマンドの登録

▪️ Laravelのコマンド自動発見が有効なら、app/Console/Commands 配下に置くだけでOK。

▪️ もし手動登録しているプロジェクトなら app/Console/Kernel.php の $commands に追記してください。

Kernel.php
protected $commands = [
    \App\Console\Commands\PruneCollectionImages::class,
];

2. 使い方

まずは必ずドライラン!

php artisan storage:prune-collection-images --dry-run

よく使うオプション

オプション 説明
--dir --dir=collection_images storage/app/public/ 配下の対象ディレクトリ
--dry-run --dry-run 削除せず、候補だけ表示
--keep --keep=favicon.png --keep=logo.png 常に残すファイル名(basename)。複数回OK

実行例

# 候補確認
php artisan storage:prune-collection-images --dir=collection_images --dry-run --keep=logo.png

# 本削除
php artisan storage:prune-collection-images --dir=collection_images --keep=logo.png

3. 何をもって「使用中」とみなすか

▪️ DBの collection_images.image_path を basename に変換し、

▪️ 対象ファイルの 「最初の _ 以降」 と比較して一致したら 使用中 と判定
例:DBに home.webp があれば、17569907_home.webp は 使用中。

▪️ _ が含まれない場合は ファイル名全体 で比較します。

4. 他アプリに流用するには

最低限、この2行だけ読み替えればOKです。

// 参照テーブルとカラムを参照アプリに合わせる
$referenced = DB::table('collection_images')->pluck('image_path')->filter();

// 対象ディレクトリ(publicディスク配下)
php artisan storage:prune-collection-images --dir=uploads

5. スケジュール実行(任意)

app/Console/Kernel.php
protected function schedule(\Illuminate\Console\Scheduling\Schedule $schedule)
{
    // 例)毎日 04:30 に実行、保護ファイルも指定
    $schedule->command('storage:prune-collection-images --dir=collection_images --keep=logo.png')
        ->dailyAt('04:30')
        ->withoutOverlapping();
}

共有レンタルサーバ(例:Xserver)では OS の cron で
php artisan schedule:run を毎分実行に設定してください。

6. よくあるハマりどころ

▪️ php artisan storage:link を忘れる
public/storagestorage/app/public にシンボリックリンクされているか確認。

▪️ 権限問題
webサーバーユーザーに書込権限があるか確認(storage 配下)。

▪️ 削除しちゃダメな共通画像
--keep に追加、または $alwaysKeep に固定登録。

▪️ 本番/開発で FILESYSTEM_DISK が違う
コマンド内を 常に Storage::disk('public') で固定しておくと安全。

.env
# 初期値
FILESYSTEM_DISK=local

↓

FILESYSTEM_DISK=public
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?