Laravelのソフトデリートとデータリストア:論理削除を安全に運用する実装パターン

Laravelのソフトデリートとデータリストア:論理削除を安全に運用する実装パターン

Laravelのソフトデリート(Soft Deletes)は、レコードを物理削除せずに「削除済み」として扱う仕組み。誤操作からの復元、監査要件、履歴保持に強い一方で、検索・ユニーク制約・関連テーブル・集計・権限などを考えずに入れると「消したはずのデータが残る」「復元できない」「ユニークが衝突する」などの運用事故が起きやすい。この記事は、マイグレーションから復元UI/API、関連データの扱い、よくある落とし穴までを実務目線でまとめる。

ソフトデリートの仕組み:deleted_at が NULL かどうかで生存判定する

Soft Deletesは、deleted_at(削除日時)カラムを持ち、通常のクエリは deleted_at IS NULL のものだけを返す。削除は UPDATE で deleted_at を埋め、復元は deleted_at を NULL に戻す。物理削除(DELETE)とは別の操作になる。

導入手順:マイグレーションに deleted_at を追加する

既存テーブルへ追加する場合は ALTER でカラムを足す。新規テーブルなら最初から入れる。

// 既存テーブルへ追加するマイグレーション例
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::table('posts', function (Blueprint $table) {
            $table->softDeletes(); // deleted_at を追加
        });
    }

    public function down(): void
    {
        Schema::table('posts', function (Blueprint $table) {
            $table->dropSoftDeletes();
        });
    }
};

モデル設定:SoftDeletesトレイトを有効化する

モデルに SoftDeletes を入れると、delete() が論理削除になる。

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;

class Post extends Model
{
    use SoftDeletes;

    protected $fillable = ['title', 'body'];
}

基本動作:delete / restore / forceDelete の違いを分けて扱う

・delete():deleted_at をセット(論理削除)
・restore():deleted_at を NULL に戻す(復元)
・forceDelete():DELETEで物理削除(復元不可)
運用上は「通常はdelete」「管理者だけforceDelete」など、権限で分けるのが基本。

$post = Post::findOrFail($id);

// 論理削除
$post->delete();

// 復元(削除済みも対象に含める)
Post::withTrashed()->findOrFail($id)->restore();

// 物理削除(復元不可)
Post::withTrashed()->findOrFail($id)->forceDelete();

取得の基本:withTrashed / onlyTrashed を使い分ける

通常のクエリは削除済みを除外する。削除済みを扱う画面や管理機能では明示的に含める。

// 生存データのみ(デフォルト)
$posts = Post::query()->latest()->get();

// 生存 + 削除済み
$all = Post::withTrashed()->latest()->get();

// 削除済みのみ(ゴミ箱一覧)
$trash = Post::onlyTrashed()->latest('deleted_at')->get();

復元の実装:ゴミ箱一覧と“戻す”エンドポイントを作る

実務では「削除済み一覧(ゴミ箱)」と「復元ボタン」がセットになる。復元対象の取得は onlyTrashed() を使うと安全。

// routes/web.php など(例)
use App\Http\Controllers\PostTrashController;

Route::get('/posts/trash', [PostTrashController::class, 'index']);
Route::post('/posts/{post}/restore', [PostTrashController::class, 'restore']);


// app/Http/Controllers/PostTrashController.php(例)
namespace App\Http\Controllers;

use App\Models\Post;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;

class PostTrashController extends Controller
{
    public function index(): View
    {
        $trash = Post::onlyTrashed()->latest('deleted_at')->paginate(20);
        return view('posts.trash', compact('trash'));
    }

    public function restore(int $post): RedirectResponse
    {
        $target = Post::onlyTrashed()->findOrFail($post);
        $target->restore();

        return redirect()->back()->with('status', '復元しました');
    }
}

ユニーク制約の落とし穴:論理削除しても“同じ値”が残る

例えば email が UNIQUE のユーザーを論理削除すると、同じ email で新規作成できない。これは「DBには残っている」ため。対処パターンは次のどれか。
・物理削除にする(復元要件が無い場合)
・削除時にユニーク値を退避して更新する(emailにサフィックス付与など)
・deleted_at を含む複合ユニークにする(DBが許す形で設計)
運用要件(復元の必要性)とセットで決める。

検索/一覧の事故:JOINや集計で“削除済みが混ざる”を制御する

SoftDeletesはモデル単体のクエリでは効くが、JOIN/サブクエリ/生SQLでは意図せず混ざることがある。
・JOIN先が削除済みなら除外する条件が要る
・集計は「生存のみ」「削除済み含む」どちらが正しいか明文化する
・レポート用途は基準日時を固定して再現性を持たせる

関連(リレーション)の扱い:親を消したら子はどうするかを決める

親テーブルを論理削除しても、子は残る。要件として次を決めないと整合が壊れる。
・親が削除済みでも子は表示するのか
・親が削除済みなら子も論理削除するのか(連鎖)
・復元時に子も復元するのか
連鎖論理削除をやる場合は、イベント/オブザーバやサービス層で “削除・復元の手順” を一箇所にまとめると事故が減る。

連鎖ソフトデリート例:削除時に子も削除、復元時に子も復元

運用で一番揉めるのは「親は復元したけど子が消えたまま」などの中途半端。方針を固定した上で実装する。

// 例:Post と Comment がある前提
// app/Models/Post.php
public function comments()
{
    return $this->hasMany(Comment::class);
}

// 削除時に子も削除(イベント/オブザーバ等で呼ぶ想定)
public function softDeleteWithChildren(): void
{
    $this->comments()->delete();
    $this->delete();
}

// 復元時に子も復元
public function restoreWithChildren(): void
{
    $this->restore();
    $this->comments()->withTrashed()->restore();
}

監査・復元期限:いつまで復元可能にするかを決める

ソフトデリートは“無限に溜まる”とストレージや検索性能に効いてくる。
・30日経過した削除済みは物理削除する
・監査要件があるなら別テーブル/ログへ移す
・個人情報の削除要件(消去要求)があるなら forceDelete を含めた運用を用意する
「復元できる期間」を決めると運用が安定する。

定期掃除:一定期間を過ぎた削除済みを物理削除する

スケジューラで定期的に掃除すると、テーブル肥大化を抑えられる。

// 例:90日より古い削除済みを物理削除
use App\Models\Post;
use Illuminate\Support\Carbon;

$threshold = Carbon::now()->subDays(90);

Post::onlyTrashed()
    ->where('deleted_at', '<', $threshold)
    ->chunkById(1000, function ($posts) {
        foreach ($posts as $post) {
            $post->forceDelete();
        }
    });

API実装の注意:削除済みIDの扱いと権限を明確にする

APIでは「/posts/{id} が404」の意味が曖昧になりやすい。
・存在しない
・削除済みで通常ユーザーには見せない
この2つを分けたい場合は、管理者用エンドポイントで withTrashed を使うなど、境界を分ける。復元やforceDeleteは必ず認可(Policy/Gate)で制御する。

よくあるエラーと対処:復元できない・二重復元・想定外の表示

・復元できない:onlyTrashed で取っていない、関連が欠けている、ユニーク衝突
・二重復元:同時操作で状態が競合→楽観ロック/排他/更新条件を検討
・削除済みが混ざる:生SQL/JOINで where deleted_at is null を入れていない
・思ったより遅い:削除済みが増えてインデックス設計が合っていない
トラブルは「削除済みを含める/含めない」の境界が曖昧なときに起きる。

まとめ:ソフトデリートを安全に回すための実務チェック

・deleted_at を追加し、モデルで SoftDeletes を有効化
・withTrashed / onlyTrashed を使い分け、ゴミ箱+復元導線を用意
・delete / restore / forceDelete を権限で分離
・ユニーク制約の衝突パターンを事前に決める
・関連データの連鎖削除/復元方針を固定して一箇所に集約
・削除済みの保存期限と定期物理削除を設計してテーブル肥大を防ぐ