Laravelのソフトデリートとデータリストア:論理削除を安全に運用する実装パターン
- 作成日 2026.02.13
- その他
Laravelのソフトデリート(Soft Deletes)は、レコードを物理削除せずに「削除済み」として扱う仕組み。誤操作からの復元、監査要件、履歴保持に強い一方で、検索・ユニーク制約・関連テーブル・集計・権限などを考えずに入れると「消したはずのデータが残る」「復元できない」「ユニークが衝突する」などの運用事故が起きやすい。この記事は、マイグレーションから復元UI/API、関連データの扱い、よくある落とし穴までを実務目線でまとめる。
- 1. ソフトデリートの仕組み:deleted_at が NULL かどうかで生存判定する
- 2. 導入手順:マイグレーションに deleted_at を追加する
- 3. モデル設定:SoftDeletesトレイトを有効化する
- 4. 基本動作:delete / restore / forceDelete の違いを分けて扱う
- 5. 取得の基本:withTrashed / onlyTrashed を使い分ける
- 6. 復元の実装:ゴミ箱一覧と“戻す”エンドポイントを作る
- 7. ユニーク制約の落とし穴:論理削除しても“同じ値”が残る
- 8. 検索/一覧の事故:JOINや集計で“削除済みが混ざる”を制御する
- 9. 関連(リレーション)の扱い:親を消したら子はどうするかを決める
- 10. 連鎖ソフトデリート例:削除時に子も削除、復元時に子も復元
- 11. 監査・復元期限:いつまで復元可能にするかを決める
- 12. 定期掃除:一定期間を過ぎた削除済みを物理削除する
- 13. API実装の注意:削除済みIDの扱いと権限を明確にする
- 14. よくあるエラーと対処:復元できない・二重復元・想定外の表示
- 15. まとめ:ソフトデリートを安全に回すための実務チェック
ソフトデリートの仕組み: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 を権限で分離
・ユニーク制約の衝突パターンを事前に決める
・関連データの連鎖削除/復元方針を固定して一箇所に集約
・削除済みの保存期限と定期物理削除を設計してテーブル肥大を防ぐ
-
前の記事
Laravelのスケジューラで定期タスクを自動化する:cron1本から安全運用まで 2026.02.12
-
次の記事
記事がありません
コメントを書く