【第四弾】Laravel入門資料

📚 Laravel公式ドキュメント - Eloquent: Getting Started

https://laravel.com/docs/12.x/eloquent#soft-deleting

💡 この講座では、上記の公式ドキュメントを基に解説していきます。
公式ドキュメントは初学者には内容が難しいため、エッセンスを優しく噛み砕いて解説していきます。

ソフトデリート(論理削除) - データを安全に削除する

ソフトデリートは、データベースから実際にレコードを削除せず、「削除フラグ」を立てて論理的に削除する機能です。

💡 ソフトデリートを使う理由

  • データの復元が可能: 誤って削除してもデータを復旧できる
  • 履歴の保持: 削除されたデータの履歴を追跡できる
  • 関連データの保護: 外部キー制約による問題を回避
  • 監査ログ: いつ誰が削除したかの記録を残せる

🎯 この章で学ぶこと

  • SoftDeletesトレイトの使い方
  • ✅ マイグレーションでdeleted_atカラムを追加
  • ✅ 削除済みデータの取得・復元・完全削除
  • ✅ 削除済み商品一覧画面の実装

論理削除 vs 物理削除

削除方法には2種類あります。それぞれの違いを理解しましょう。

❌ 物理削除(Physical Delete)

データベースからレコードを完全に削除する方法

DELETE FROM products WHERE id = 1;

特徴:

  • データが永久に失われる
  • 復元不可能
  • ディスク容量を節約
  • 監査ログが残らない

✅ 論理削除(Logical Delete / Soft Delete)

データは残したまま、削除フラグを立てる方法

UPDATE products
SET deleted_at = '2025-10-30 12:00:00'
WHERE id = 1;

特徴:

  • データは残る
  • 復元可能
  • 履歴として保持
  • 監査ログとして機能

SoftDeletesトレイトは必須?

SoftDeletesトレイトを宣言しないとどうなるか、挙動の違いを理解しましょう。

⚠️ トレイトなしの場合の挙動

1. delete()を実行した場合

// トレイトなし
class Product extends Model
{
    // use SoftDeletes; ← これがない
}

$product = Product::find(1);
$product->delete();

結果: データベースから完全に削除される(物理削除)
deleted_atカラムは更新されない
→ データは永久に失われる
エラーは出ない

2. ソフトデリート専用メソッドを使った場合

// トレイトなし
Product::withTrashed()->get();  // ❌ エラー
$product->restore();            // ❌ エラー
Product::onlyTrashed()->get();  // ❌ エラー

結果: Call to undefined method エラーになる
→ これらのメソッドはトレイトによって提供されるため

📊 挙動の比較表

操作 トレイトなし トレイトあり
delete() 物理削除される 論理削除される
withTrashed() ❌ エラー ✅ 動作する
onlyTrashed() ❌ エラー ✅ 動作する
restore() ❌ エラー ✅ 動作する
forceDelete() ❌ エラー ✅ 物理削除できる

💡 結論

SoftDeletesトレイトは必須ではありませんが:
delete()を使った場合 → エラーにならないが物理削除になる
• ソフトデリート専用メソッド → エラーになる(メソッドが存在しないため)

ソフトデリート機能を使うには、必ずトレイトを宣言する必要があります。

ステップ1: マイグレーションの作成

既存のproductsテーブルにdeleted_atカラムを追加します。

# 新しいマイグレーションファイルを作成
php artisan make:migration add_soft_deletes_to_products_table

database/migrations/xxxx_add_soft_deletes_to_products_table.php

<?php

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('products', function (Blueprint $table) {
            $table->softDeletes(); // deleted_at カラムを追加
        });
    }

    public function down(): void
    {
        Schema::table('products', function (Blueprint $table) {
            $table->dropSoftDeletes();
        });
    }
};
# マイグレーションを実行
php artisan migrate

💡 softDeletes()メソッドとは?

softDeletes()は、deleted_atというTIMESTAMP型のNULL許可カラムを自動的に追加します。
削除されていないデータはdeleted_at = NULL、削除されたデータには削除日時が入ります。

ステップ2: モデルの修正

ProductモデルにSoftDeletesトレイトを追加します。

app/Models/Product.php

<?php

namespace App\Models;

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

class Product extends Model
{
    use HasFactory, SoftDeletes;

    protected $fillable = [
        'name',
        'description',
        'price',
        'stock',
        'is_published',
        'category_id',
    ];

    // この商品が属するカテゴリー
    public function category()
    {
        return $this->belongsTo(Category::class);
    }
}

🎯 SoftDeletesトレイトが提供する機能

  • $product->delete() - ソフトデリート実行(deleted_atに現在時刻を設定)
  • $product->restore() - 削除したデータを復元
  • $product->forceDelete() - 物理削除(完全削除)
  • Product::withTrashed() - 削除済みも含めて取得
  • Product::onlyTrashed() - 削除済みのみ取得

ステップ3: コントローラーの削除処理

削除処理は変更不要です。delete()メソッドが自動的にソフトデリートを実行します。

app/Http/Controllers/ProductController.php

// 商品削除
public function destroy(Product $product)
{
    $product->delete(); // ソフトデリート実行(deleted_atに現在時刻が入る)

    return redirect()->route('products.index')
                     ->with('success', '商品を削除しました');
}

⚠️ 重要なポイント

SoftDeletesトレイトを追加すると、delete()メソッドの動作が自動的に変わります。
データベースから実際に削除されるのではなく、deleted_atに現在時刻が入るだけになります。

ステップ4: 削除済みデータの取得

通常のクエリでは削除済みデータは取得されません。削除済みデータを扱う方法を学びましょう。

通常の取得(削除済みは除外される)

// deleted_at が NULL のデータのみ取得
$products = Product::all();

削除済みも含めて取得

// deleted_at の値に関係なく全て取得
$products = Product::withTrashed()->get();

削除済みのみ取得

// deleted_at が NULL でないデータのみ取得
$products = Product::onlyTrashed()->get();

💡 クエリの動作

メソッド 取得されるデータ SQL条件
Product::all() 削除されていないデータのみ deleted_at IS NULL
Product::withTrashed()->get() 全てのデータ 条件なし
Product::onlyTrashed()->get() 削除済みデータのみ deleted_at IS NOT NULL

ステップ5: 復元機能の実装

削除した商品を復元する機能を追加します。

ルート定義

routes/web.php

// 削除済み商品一覧
Route::get('/products/trashed', [ProductController::class, 'trashed'])->name('products.trashed');

// 商品復元
Route::patch('/products/{id}/restore', [ProductController::class, 'restore'])->name('products.restore');

// 完全削除
Route::delete('/products/{id}/force-delete', [ProductController::class, 'forceDelete'])->name('products.forceDelete');

コントローラー

app/Http/Controllers/ProductController.php

// 削除済み商品一覧
public function trashed()
{
    $products = Product::onlyTrashed()
                       ->with('category')
                       ->latest('deleted_at')
                       ->paginate(10);

    return view('products.trashed', compact('products'));
}

// 商品復元
public function restore($id)
{
    $product = Product::onlyTrashed()->findOrFail($id);
    $product->restore();

    return redirect()->route('products.trashed')
                     ->with('success', '商品を復元しました');
}

// 完全削除
public function forceDelete($id)
{
    $product = Product::onlyTrashed()->findOrFail($id);
    $product->forceDelete();

    return redirect()->route('products.trashed')
                     ->with('success', '商品を完全に削除しました');
}

🎯 メソッドの説明

  • onlyTrashed() - 削除済みデータのみを対象にする
  • restore() - deleted_atをNULLに戻して復元
  • forceDelete() - データベースから完全に削除

ステップ6: 削除済み商品一覧ビュー

resources/views/products/trashed.blade.php を作成

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>削除済み商品一覧</title>
    <script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-100">
    <div class="container mx-auto px-4 py-8">
        <div class="flex justify-between items-center mb-6">
            <h1 class="text-3xl font-bold text-gray-800">削除済み商品一覧</h1>
            <a href="{{ route('products.index') }}"
               class="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded">
                通常一覧に戻る
            </a>
        </div>

        @if(session('success'))
            <div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">
                {{ session('success') }}
            </div>
        @endif

        <div class="bg-white shadow-md rounded-lg overflow-hidden">
            <table class="min-w-full">
                <thead class="bg-gray-200">
                    <tr>
                        <th class="px-6 py-3 text-left text-xs font-medium text-gray-700 uppercase">ID</th>
                        <th class="px-6 py-3 text-left text-xs font-medium text-gray-700 uppercase">商品名</th>
                        <th class="px-6 py-3 text-left text-xs font-medium text-gray-700 uppercase">カテゴリー</th>
                        <th class="px-6 py-3 text-left text-xs font-medium text-gray-700 uppercase">削除日時</th>
                        <th class="px-6 py-3 text-left text-xs font-medium text-gray-700 uppercase">操作</th>
                    </tr>
                </thead>
                <tbody class="bg-white divide-y divide-gray-200">
                    @forelse($products as $product)
                        <tr>
                            <td class="px-6 py-4">{{ $product->id }}</td>
                            <td class="px-6 py-4">{{ $product->name }}</td>
                            <td class="px-6 py-4">{{ $product->category->name }}</td>
                            <td class="px-6 py-4">{{ $product->deleted_at->format('Y-m-d H:i') }}</td>
                            <td class="px-6 py-4">
                                <form action="{{ route('products.restore', $product->id) }}" method="POST" class="inline">
                                    @csrf
                                    @method('PATCH')
                                    <button type="submit"
                                            class="bg-green-500 hover:bg-green-600 text-white px-3 py-1 rounded text-sm">
                                        復元
                                    </button>
                                </form>
                                <form action="{{ route('products.forceDelete', $product->id) }}" method="POST" class="inline">
                                    @csrf
                                    @method('DELETE')
                                    <button type="submit"
                                            onclick="return confirm('完全に削除します。よろしいですか?')"
                                            class="bg-red-500 hover:bg-red-600 text-white px-3 py-1 rounded text-sm">
                                        完全削除
                                    </button>
                                </form>
                            </td>
                        </tr>
                    @empty
                        <tr>
                            <td colspan="5" class="px-6 py-4 text-center text-gray-500">
                                削除済み商品はありません
                            </td>
                        </tr>
                    @endforelse
                </tbody>
            </table>
        </div>

        <div class="mt-6">
            {{ $products->links() }}
        </div>
    </div>
</body>
</html>

ステップ7: 商品一覧に削除済み一覧へのリンクを追加

通常の商品一覧から削除済み商品一覧にアクセスできるようにします。

resources/views/products/index.blade.php

<div class="flex justify-between items-center mb-6">
    <h1 class="text-3xl font-bold text-gray-800">商品一覧</h1>
    <div class="flex space-x-3">
        <a href="{{ route('products.trashed') }}"
           class="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded">
            削除済み商品
        </a>
        <a href="{{ route('products.create') }}"
           class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded">
            新規登録
        </a>
    </div>
</div>

動作確認

🔍 確認手順

  1. 商品一覧で商品を削除 → 削除フラグが立つ(deleted_atに日時が入る)
  2. 商品一覧から削除した商品が見えなくなる
  3. 「削除済み商品」リンクをクリック
  4. 削除した商品が表示される
  5. 「復元」ボタンで商品を復元 → deleted_atがNULLになる
  6. 通常一覧に戻ると復元した商品が表示される
  7. 「完全削除」ボタンでデータベースから物理削除される

便利なメソッド

💡 その他の便利なメソッド

  • $product->trashed() - 削除済みかどうかを判定(true/false)
  • Product::withTrashed()->find($id) - IDで検索(削除済みも対象)
  • Product::withTrashed()->where(...) - 条件検索(削除済みも対象)

✅ ソフトデリートの実装が完了しました!

まとめ

ソフトデリート(論理削除)機能の実装を学びました!

学んだこと

  • SoftDeletes トレイトでソフトデリートを実装
  • deleted_at カラムで削除日時を記録
  • withTrashed() で削除済みデータを含めて取得
  • onlyTrashed() で削除済みデータのみ取得
  • restore() でデータを復元
  • forceDelete() で完全削除

次のステップ

ソフトデリート機能を習得しました。
次は「ページネーション」タブで、大量のデータを分割表示する方法を学習しましょう!

✅ ソフトデリート学習完了です!