【第四弾】Laravel入門資料

📚 Laravel公式ドキュメント

この総合実践では、以下の公式ドキュメントで学んだ知識を全て統合します:

💡 これまでの学習を振り返りながら、実践的なアプリケーションを構築しましょう。

総合実践: TODOアプリを作ろう

これまで学んだ全ての知識を使って、実践的なTODOアプリを作成します。
認証、CRUD、ロール管理、ページネーション、検索など、全てを統合したアプリケーションです。

🎯 このアプリで使う技術

  • 認証: ユーザー登録・ログイン・ログアウト
  • CRUD: タスクの作成・表示・更新・削除
  • ロール管理: 管理者と一般ユーザーの権限分け
  • リレーション: ユーザーとタスクの1対多
  • ページネーション: タスク一覧のページ分け
  • 検索・フィルタリング: ステータス別、キーワード検索
  • バリデーション: 入力値チェック
  • ミドルウェア: 認証・権限チェック

📋 アプリの機能

  • 一般ユーザー: 自分のタスクの作成・編集・削除・完了
  • 管理者: 全ユーザーのタスクを閲覧・削除可能
  • ステータス管理: 未着手・進行中・完了の3状態
  • 優先度: 低・中・高の3段階
  • 期限設定: タスクの期限を設定

ステップ1: データベース設計

⚠️ Laravelプロジェクトがまだの場合
Windows環境構築セクションまたはMac環境構築セクションを参照して、プロジェクトを作成してください。

必要なテーブル

users テーブル
├─ id
├─ name
├─ email (unique)
├─ password
├─ role (default: 'user')  ← 'user' or 'admin'
├─ created_at
└─ updated_at

tasks テーブル
├─ id
├─ user_id (外部キー → users.id)
├─ title
├─ description (nullable)
├─ status (default: 'pending')  ← 'pending', 'in_progress', 'completed'
├─ priority (default: 'medium')  ← 'low', 'medium', 'high'
├─ due_date (nullable)
├─ image (nullable)  ← 画像ファイルパス
├─ completed_at (nullable)
├─ created_at
└─ updated_at

マイグレーション作成

# usersテーブルにroleカラム追加
php artisan make:migration add_role_to_users_table

# tasksテーブル作成
php artisan make:migration create_tasks_table
// database/migrations/xxxx_add_role_to_users_table.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('users', function (Blueprint $table) {
            $table->enum('role', ['admin', 'user'])->default('user')->after('password');
        });
    }

    public function down(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->dropColumn('role');
        });
    }
};
// database/migrations/xxxx_create_tasks_table.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::create('tasks', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained()->onDelete('cascade');
            $table->string('title');
            $table->text('description')->nullable();
            $table->enum('status', ['pending', 'in_progress', 'completed'])->default('pending');
            $table->enum('priority', ['low', 'medium', 'high'])->default('medium');
            $table->date('due_date')->nullable();
            $table->string('image')->nullable(); // 画像パス
            $table->timestamp('completed_at')->nullable();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('tasks');
    }
};
# マイグレーション実行
php artisan migrate

✅ データベース設計が完了しました!

ステップ2: モデル作成

Userモデルの修正

// app/Models/User.php

namespace App\Models;

use Illuminate\Foundation\Auth\User as Authenticatable;

class User extends Authenticatable
{
    protected $fillable = [
        'name',
        'email',
        'password',
        'role',
    ];

    protected $hidden = [
        'password',
        'remember_token',
    ];

    protected $casts = [
        'email_verified_at' => 'datetime',
        'password' => 'hashed',
    ];

    /**
     * ユーザーが持つタスク
     */
    public function tasks()
    {
        return $this->hasMany(Task::class);
    }

    /**
     * 管理者かどうか
     */
    public function isAdmin(): bool
    {
        return $this->role === 'admin';
    }

    /**
     * 特定のロールを持っているか
     */
    public function hasRole(string $role): bool
    {
        return $this->role === $role;
    }
}

Taskモデルの作成

# Taskモデル作成
php artisan make:model Task
// app/Models/Task.php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;

class Task extends Model
{
    protected $fillable = [
        'user_id',
        'title',
        'description',
        'status',
        'priority',
        'due_date',
        'image',
        'completed_at',
    ];

    protected $casts = [
        'due_date' => 'date',
        'completed_at' => 'datetime',
    ];

    /**
     * タスクの所有者
     */
    public function user()
    {
        return $this->belongsTo(User::class);
    }

    /**
     * 完了済みかどうか
     */
    public function isCompleted(): bool
    {
        return $this->status === 'completed';
    }

    /**
     * 期限切れかどうか
     */
    public function isOverdue(): bool
    {
        if (!$this->due_date || $this->isCompleted()) {
            return false;
        }

        return $this->due_date->isPast();
    }

    /**
     * タスクを完了にする
     */
    public function markAsCompleted(): void
    {
        $this->update([
            'status' => 'completed',
            'completed_at' => now(),
        ]);
    }

    /**
     * 画像のURLを取得
     */
    public function getImageUrl(): ?string
    {
        if (!$this->image) {
            return null;
        }

        return Storage::url($this->image);
    }

    /**
     * 優先度の色を取得(Tailwind CSS用)
     */
    public function getPriorityColor(): string
    {
        return match($this->priority) {
            'low' => 'green',
            'medium' => 'yellow',
            'high' => 'red',
            default => 'gray',
        };
    }

    /**
     * ステータスの色を取得
     */
    public function getStatusColor(): string
    {
        return match($this->status) {
            'pending' => 'gray',
            'in_progress' => 'blue',
            'completed' => 'green',
            default => 'gray',
        };
    }

    /**
     * 優先度の日本語名を取得
     */
    public function getPriorityLabel(): string
    {
        return match($this->priority) {
            'low' => '低',
            'medium' => '中',
            'high' => '高',
            default => '不明',
        };
    }

    /**
     * ステータスの日本語名を取得
     */
    public function getStatusLabel(): string
    {
        return match($this->status) {
            'pending' => '未着手',
            'in_progress' => '進行中',
            'completed' => '完了',
            default => '不明',
        };
    }
}

✅ モデルの作成が完了しました!

ステップ3: 認証機能の実装

認証コントローラーの作成

# コントローラー作成
php artisan make:controller Auth/RegisterController
php artisan make:controller Auth/LoginController
php artisan make:controller Auth/LogoutController
// app/Http/Controllers/Auth/RegisterController.php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

class RegisterController extends Controller
{
    public function showRegistrationForm()
    {
        return view('auth.register');
    }

    public function register(Request $request)
    {
        $validated = $request->validate([
            'name' => 'required|string|max:255',
            'email' => 'required|string|email|max:255|unique:users',
            'password' => 'required|string|min:8|confirmed',
        ]);

        $user = User::create([
            'name' => $validated['name'],
            'email' => $validated['email'],
            'password' => $validated['password'],
        ]);

        Auth::login($user);

        return redirect()->route('tasks.index')
            ->with('success', 'アカウントを作成しました!');
    }
}
// app/Http/Controllers/Auth/LoginController.php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

class LoginController extends Controller
{
    public function showLoginForm()
    {
        return view('auth.login');
    }

    public function login(Request $request)
    {
        $credentials = $request->validate([
            'email' => 'required|email',
            'password' => 'required',
        ]);

        if (Auth::attempt($credentials)) {
            $request->session()->regenerate();

            return redirect()->intended(route('tasks.index'))
                ->with('success', 'ログインしました!');
        }

        return back()->withErrors([
            'email' => 'メールアドレスまたはパスワードが正しくありません。',
        ])->onlyInput('email');
    }
}
// app/Http/Controllers/Auth/LogoutController.php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

class LogoutController extends Controller
{
    public function logout(Request $request)
    {
        Auth::logout();
        $request->session()->invalidate();
        $request->session()->regenerateToken();

        return redirect()->route('login')
            ->with('success', 'ログアウトしました。');
    }
}

認証ルートの設定

// routes/web.php

use App\Http\Controllers\Auth\RegisterController;
use App\Http\Controllers\Auth\LoginController;
use App\Http\Controllers\Auth\LogoutController;

// ゲスト専用ルート
Route::middleware('guest')->group(function () {
    Route::get('/register', [RegisterController::class, 'showRegistrationForm'])->name('register');
    Route::post('/register', [RegisterController::class, 'register']);
    Route::get('/login', [LoginController::class, 'showLoginForm'])->name('login');
    Route::post('/login', [LoginController::class, 'login']);
});

// 認証済みユーザー専用
Route::middleware('auth')->group(function () {
    Route::post('/logout', [LogoutController::class, 'logout'])->name('logout');
});

✅ 認証機能の実装が完了しました!

ステップ4: タスクのCRUD実装

TaskControllerの作成

# リソースコントローラー作成
php artisan make:controller TaskController --resource
// app/Http/Controllers/TaskController.php

namespace App\Http\Controllers;

use App\Models\Task;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;

class TaskController extends Controller
{
    /**
     * タスク一覧表示(ページネーション・検索付き)
     */
    public function index(Request $request)
    {
        $query = Task::query();

        // ログインユーザーのタスクのみ(管理者は全タスク表示)
        if (!auth()->user()->isAdmin()) {
            $query->where('user_id', auth()->id());
        } else {
            // 管理者の場合はユーザー情報も一緒に取得
            $query->with('user');
        }

        // ステータスフィルタ
        if ($request->filled('status')) {
            $query->where('status', $request->status);
        }

        // 優先度フィルタ
        if ($request->filled('priority')) {
            $query->where('priority', $request->priority);
        }

        // キーワード検索
        if ($request->filled('search')) {
            $query->where(function($q) use ($request) {
                $q->where('title', 'like', '%' . $request->search . '%')
                  ->orWhere('description', 'like', '%' . $request->search . '%');
            });
        }

        // 並び順(期限が近い順)
        $query->orderByRaw("CASE WHEN status = 'completed' THEN 1 ELSE 0 END")
              ->orderBy('due_date', 'asc')
              ->orderBy('created_at', 'desc');

        $tasks = $query->paginate(10)->withQueryString();

        return view('tasks.index', compact('tasks'));
    }

    /**
     * 📝 補足説明
     *
     * Task::query()とは?
     * - クエリビルダーのインスタンスを取得するメソッド
     * - 条件分岐で動的にクエリを組み立てる時に使う
     *
     * 例:
     * $query = Task::query();  // クエリビルダー取得
     * if ($status) {
     *     $query->where('status', $status);  // 条件があれば追加
     * }
     * $tasks = $query->get();
     *
     * ----------------------------------------
     *
     * サブクエリ(where内の無名関数)とは?
     * - OR条件をグループ化して、他の検索条件と組み合わせるために使う
     *
     * 例:
     * $query->where(function($q) use ($request) {
     *     $q->where('title', 'like', '%' . $request->search . '%')
     *       ->orWhere('description', 'like', '%' . $request->search . '%');
     * });
     *
     * これにより生成されるSQL:
     * WHERE (title LIKE '%keyword%' OR description LIKE '%keyword%') AND 他の条件
     *
     * もしwhere()を使わずにorWhere()だけ書くと:
     * WHERE title LIKE '%keyword%' OR description LIKE '%keyword%' OR 他の条件
     * → 意図しない結果になる!
     *
     * ----------------------------------------
     *
     * orderByRaw()とCASE文
     * - orderByRaw(): 生のSQL文で並び替え
     * - CASE文: SQLの条件分岐(if文のようなもの)
     *
     * 例:
     * $query->orderByRaw("CASE WHEN status = 'completed' THEN 1 ELSE 0 END")
     *
     * 意味:
     * - status = 'completed'(完了)なら「1」を返す
     * - それ以外(未完了)なら「0」を返す
     * - 0 < 1 なので、未完了が先、完了が後に並ぶ
     *
     * 複数のorderBy()の優先順位:
     * ->orderByRaw("CASE WHEN status = 'completed' THEN 1 ELSE 0 END")  // ①完了を後ろに
     * ->orderBy('due_date', 'asc')      // ②期限が近い順
     * ->orderBy('created_at', 'desc');  // ③期限が同じ場合は新しい順
     *
     * 結果:
     * 未完了タスク(期限が近い順)
     *   ├ 2025-01-05 締切
     *   └ 2025-01-10 締切
     * 完了済みタスク(期限が近い順)
     *   └ 2025-01-01 締切
     *
     * ----------------------------------------
     *
     * withQueryString()とは?
     * - ページネーションリンクに現在のクエリパラメータを保持するメソッド
     * - 検索条件やフィルタがある場合、ページ移動しても条件が消えない
     *
     * 例:
     * URL: /tasks?status=completed&search=Laravel&page=2
     *
     * withQueryString()なし:
     * → 次ページのリンク: /tasks?page=3(検索条件が消える!)
     *
     * withQueryString()あり:
     * → 次ページのリンク: /tasks?status=completed&search=Laravel&page=3(条件保持!)
     */

    /**
     * タスク作成フォーム表示
     */
    public function create()
    {
        return view('tasks.create');
    }

    /**
     * タスク保存
     */
    public function store(Request $request)
    {
        $validated = $request->validate([
            'title' => 'required|string|max:255',
            'description' => 'nullable|string',
            'priority' => 'required|in:low,medium,high',
            'due_date' => 'nullable|date|after_or_equal:today',
            'image' => 'nullable|image|mimes:jpeg,png,jpg,gif|max:2048',
        ]);

        // 画像アップロード処理
        if ($request->hasFile('image')) {
            $validated['image'] = $request->file('image')->store('tasks', 'public');
        }

        auth()->user()->tasks()->create($validated);

        return redirect()->route('tasks.index')
            ->with('success', 'タスクを作成しました!');
    }

    /**
     * タスク詳細表示
     */
    public function show(Task $task)
    {
        // 自分のタスクまたは管理者のみ閲覧可能
        if ($task->user_id !== auth()->id() && !auth()->user()->isAdmin()) {
            abort(403, 'このタスクを閲覧する権限がありません。');
        }

        return view('tasks.show', compact('task'));
    }

    /**
     * タスク編集フォーム表示
     */
    public function edit(Task $task)
    {
        // 自分のタスクのみ編集可能
        if ($task->user_id !== auth()->id()) {
            abort(403, 'このタスクを編集する権限がありません。');
        }

        return view('tasks.edit', compact('task'));
    }

    /**
     * タスク更新
     */
    public function update(Request $request, Task $task)
    {
        // 自分のタスクのみ更新可能
        if ($task->user_id !== auth()->id()) {
            abort(403, 'このタスクを更新する権限がありません。');
        }

        $validated = $request->validate([
            'title' => 'required|string|max:255',
            'description' => 'nullable|string',
            'status' => 'required|in:pending,in_progress,completed',
            'priority' => 'required|in:low,medium,high',
            'due_date' => 'nullable|date',
            'image' => 'nullable|image|mimes:jpeg,png,jpg,gif|max:2048',
        ]);

        // 画像アップロード処理
        if ($request->hasFile('image')) {
            // 古い画像を削除
            if ($task->image) {
                Storage::disk('public')->delete($task->image);
            }
            $validated['image'] = $request->file('image')->store('tasks', 'public');
        }

        // ステータスが完了に変わった場合、completed_atを設定
        if ($validated['status'] === 'completed' && $task->status !== 'completed') {
            $validated['completed_at'] = now();
        } elseif ($validated['status'] !== 'completed') {
            $validated['completed_at'] = null;
        }

        $task->update($validated);

        return redirect()->route('tasks.index')
            ->with('success', 'タスクを更新しました!');
    }

    /**
     * タスク削除
     */
    public function destroy(Task $task)
    {
        // 自分のタスクまたは管理者のみ削除可能
        if ($task->user_id !== auth()->id() && !auth()->user()->isAdmin()) {
            abort(403, 'このタスクを削除する権限がありません。');
        }

        // 画像も削除
        if ($task->image) {
            Storage::disk('public')->delete($task->image);
        }

        $task->delete();

        return redirect()->route('tasks.index')
            ->with('success', 'タスクを削除しました。');
    }

    /**
     * タスクを完了にする
     */
    public function complete(Task $task)
    {
        // 自分のタスクのみ完了可能
        if ($task->user_id !== auth()->id()) {
            abort(403, 'このタスクを完了する権限がありません。');
        }

        $task->markAsCompleted();

        return back()->with('success', 'タスクを完了しました!');
    }

    /**
     * 画像を削除
     */
    public function deleteImage(Task $task)
    {
        // 自分のタスクのみ画像削除可能
        if ($task->user_id !== auth()->id()) {
            abort(403, 'このタスクの画像を削除する権限がありません。');
        }

        if ($task->image) {
            Storage::disk('public')->delete($task->image);
            $task->update(['image' => null]);
        }

        return back()->with('success', '画像を削除しました。');
    }
}

ルート設定

// routes/web.php

use App\Http\Controllers\TaskController;

Route::middleware('auth')->group(function () {
    // タスク一覧をトップページに
    Route::get('/', [TaskController::class, 'index'])->name('tasks.index');

    // タスクのリソースルート
    Route::resource('tasks', TaskController::class);

    // タスク完了
    Route::post('/tasks/{task}/complete', [TaskController::class, 'complete'])
        ->name('tasks.complete');
});

✅ タスクのCRUD実装が完了しました!

ステップ5: ストレージ設定

シンボリックリンクの作成

アップロードした画像をブラウザからアクセスできるようにします:

# シンボリックリンク作成
php artisan storage:link

これにより storage/app/publicpublic/storage にリンクされ、
アップロードした画像が /storage/tasks/xxx.jpg のURLでアクセス可能になります。

config/filesystems.phpの確認

// config/filesystems.php

'disks' => [
    'public' => [
        'driver' => 'local',
        'root' => storage_path('app/public'),
        'url' => env('APP_URL').'/storage',
        'visibility' => 'public',
        'throw' => false,
    ],
    // ...
],

✅ ストレージ設定が完了しました!

ステップ6: ビューファイルの作成

実際に使えるTODOアプリにするため、全てのビューファイルを作成します。

レイアウトファイル(共通部分)

// resources/views/layouts/app.blade.php

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>@yield('title', 'TODOアプリ')</title>
    <script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-100">
    {{-- ナビゲーション --}}
    <nav class="bg-white shadow-sm">
        <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
            <div class="flex justify-between h-16">
                <div class="flex items-center">
                    <a href="{{ route('tasks.index') }}" class="text-xl font-bold text-blue-600">
                        📝 TODOアプリ
                    </a>
                </div>
                <div class="flex items-center gap-4">
                    @auth
                        <span class="text-gray-700">{{ auth()->user()->name }}</span>
                        @if(auth()->user()->isAdmin())
                            <span class="px-2 py-1 bg-red-100 text-red-800 text-xs rounded">管理者</span>
                        @endif
                        <form method="POST" action="{{ route('logout') }}">
                            @csrf
                            <button type="submit" class="text-gray-600 hover:text-gray-900">
                                ログアウト
                            </button>
                        </form>
                    @endauth
                </div>
            </div>
        </div>
    </nav>

    {{-- フラッシュメッセージ --}}
    @if(session('success'))
        <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mt-4">
            <div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded">
                {{ session('success') }}
            </div>
        </div>
    @endif

    {{-- メインコンテンツ --}}
    <main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
        @yield('content')
    </main>
</body>
</html>

ログインページ

// resources/views/auth/login.blade.php

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>ログイン - TODOアプリ</title>
    <script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-100">
    <div class="min-h-screen flex items-center justify-center">
        <div class="max-w-md w-full bg-white rounded-lg shadow-md p-8">
            <h2 class="text-2xl font-bold text-center mb-6">📝 TODOアプリ ログイン</h2>

            @if($errors->any())
                <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
                    @foreach($errors->all() as $error)
                        <p>{{ $error }}</p>
                    @endforeach
                </div>
            @endif

            <form method="POST" action="{{ route('login') }}">
                @csrf

                <div class="mb-4">
                    <label class="block text-gray-700 text-sm font-bold mb-2">
                        メールアドレス
                    </label>
                    <input type="email" name="email" value="{{ old('email') }}"
                           class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
                           required autofocus>
                </div>

                <div class="mb-6">
                    <label class="block text-gray-700 text-sm font-bold mb-2">
                        パスワード
                    </label>
                    <input type="password" name="password"
                           class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
                           required>
                </div>

                <button type="submit"
                        class="w-full bg-blue-600 text-white py-2 px-4 rounded hover:bg-blue-700 transition">
                    ログイン
                </button>

                <p class="text-center mt-4 text-sm text-gray-600">
                    アカウントをお持ちでない方は
                    <a href="{{ route('register') }}" class="text-blue-600 hover:underline">新規登録</a>
                </p>
            </form>
        </div>
    </div>
</body>
</html>

ユーザー登録ページ

// resources/views/auth/register.blade.php

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>新規登録 - TODOアプリ</title>
    <script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-100">
    <div class="min-h-screen flex items-center justify-center">
        <div class="max-w-md w-full bg-white rounded-lg shadow-md p-8">
            <h2 class="text-2xl font-bold text-center mb-6">📝 TODOアプリ 新規登録</h2>

            @if($errors->any())
                <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
                    @foreach($errors->all() as $error)
                        <p>{{ $error }}</p>
                    @endforeach
                </div>
            @endif

            <form method="POST" action="{{ route('register') }}">
                @csrf

                <div class="mb-4">
                    <label class="block text-gray-700 text-sm font-bold mb-2">名前</label>
                    <input type="text" name="name" value="{{ old('name') }}"
                           class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
                           required autofocus>
                </div>

                <div class="mb-4">
                    <label class="block text-gray-700 text-sm font-bold mb-2">メールアドレス</label>
                    <input type="email" name="email" value="{{ old('email') }}"
                           class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
                           required>
                </div>

                <div class="mb-4">
                    <label class="block text-gray-700 text-sm font-bold mb-2">パスワード</label>
                    <input type="password" name="password"
                           class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
                           required>
                </div>

                <div class="mb-6">
                    <label class="block text-gray-700 text-sm font-bold mb-2">パスワード(確認)</label>
                    <input type="password" name="password_confirmation"
                           class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
                           required>
                </div>

                <button type="submit"
                        class="w-full bg-blue-600 text-white py-2 px-4 rounded hover:bg-blue-700 transition">
                    登録
                </button>

                <p class="text-center mt-4 text-sm text-gray-600">
                    既にアカウントをお持ちの方は
                    <a href="{{ route('login') }}" class="text-blue-600 hover:underline">ログイン</a>
                </p>
            </form>
        </div>
    </div>
</body>
</html>

✅ 認証ビューの作成が完了しました!

ステップ7: タスク関連のビュー

タスク一覧ページ

検索・フィルタリング・ページネーション機能付きの一覧ページです。

{{-- resources/views/tasks/index.blade.php --}}

@extends('layouts.app')

@section('title', 'タスク一覧')

@section('content')
    <div class="flex justify-between items-center mb-6">
        <h1 class="text-2xl font-bold text-gray-800">タスク一覧</h1>
        <a href="{{ route('tasks.create') }}"
           class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition">
            + 新規タスク作成
        </a>
    </div>

    {{-- 検索・フィルタ --}}
    <div class="bg-white rounded-lg shadow p-4 mb-6">
        <form method="GET" action="{{ route('tasks.index') }}" class="flex flex-wrap gap-4">
            <input type="text" name="search" value="{{ request('search') }}"
                   placeholder="キーワード検索"
                   class="flex-1 min-w-[200px] px-3 py-2 border border-gray-300 rounded">

            <select name="status" class="px-3 py-2 border border-gray-300 rounded">
                <option value="">ステータス: すべて</option>
                <option value="pending" {{ request('status') == 'pending' ? 'selected' : '' }}>未着手</option>
                <option value="in_progress" {{ request('status') == 'in_progress' ? 'selected' : '' }}>進行中</option>
                <option value="completed" {{ request('status') == 'completed' ? 'selected' : '' }}>完了</option>
            </select>

            <select name="priority" class="px-3 py-2 border border-gray-300 rounded">
                <option value="">優先度: すべて</option>
                <option value="low" {{ request('priority') == 'low' ? 'selected' : '' }}>低</option>
                <option value="medium" {{ request('priority') == 'medium' ? 'selected' : '' }}>中</option>
                <option value="high" {{ request('priority') == 'high' ? 'selected' : '' }}>高</option>
            </select>

            <button type="submit" class="bg-gray-600 text-white px-4 py-2 rounded hover:bg-gray-700">
                検索
            </button>
            <a href="{{ route('tasks.index') }}" class="bg-gray-300 text-gray-700 px-4 py-2 rounded hover:bg-gray-400">
                クリア
            </a>
        </form>
    </div>

    {{-- タスク一覧 --}}
    @if($tasks->count() > 0)
        <div class="space-y-4">
            @foreach($tasks as $task)
                <div class="bg-white rounded-lg shadow p-6 hover:shadow-lg transition">
                    <div class="flex items-start justify-between">
                        <div class="flex-1">
                            <div class="flex items-center gap-2 mb-2">
                                <span class="px-2 py-1 text-xs rounded
                                    {{ $task->getPriorityColor() == 'high' ? 'bg-red-100 text-red-800' : '' }}
                                    {{ $task->getPriorityColor() == 'medium' ? 'bg-yellow-100 text-yellow-800' : '' }}
                                    {{ $task->getPriorityColor() == 'low' ? 'bg-green-100 text-green-800' : '' }}">
                                    優先度: {{ $task->getPriorityLabel() }}
                                </span>
                                <span class="px-2 py-1 text-xs rounded
                                    {{ $task->getStatusColor() == 'completed' ? 'bg-green-100 text-green-800' : '' }}
                                    {{ $task->getStatusColor() == 'in_progress' ? 'bg-blue-100 text-blue-800' : '' }}
                                    {{ $task->getStatusColor() == 'pending' ? 'bg-gray-100 text-gray-800' : '' }}">
                                    {{ $task->getStatusLabel() }}
                                </span>
                                @if($task->isOverdue())
                                    <span class="px-2 py-1 text-xs rounded bg-red-600 text-white">期限切れ</span>
                                @endif
                            </div>

                            <h3 class="text-lg font-bold text-gray-800 mb-2">
                                <a href="{{ route('tasks.show', $task) }}" class="hover:text-blue-600">
                                    {{ $task->title }}
                                </a>
                            </h3>

                            @if($task->description)
                                <p class="text-gray-600 mb-2">{{ Str::limit($task->description, 100) }}</p>
                            @endif

                            <div class="text-sm text-gray-500">
                                @if($task->due_date)
                                    <span>期限: {{ $task->due_date->format('Y年m月d日') }}</span>
                                @endif
                                @if(auth()->user()->isAdmin())
                                    <span class="ml-4">作成者: {{ $task->user->name }}</span>
                                @endif
                            </div>
                        </div>

                        {{-- 画像サムネイル --}}
                        @if($task->image)
                            <img src="{{ $task->getImageUrl() }}" alt="タスク画像"
                                 class="w-24 h-24 object-cover rounded ml-4">
                        @endif
                    </div>

                    {{-- アクション --}}
                    <div class="flex gap-2 mt-4">
                        @if($task->user_id == auth()->id() && !$task->isCompleted())
                            <form method="POST" action="{{ route('tasks.complete', $task) }}">
                                @csrf
                                <button type="submit" class="text-green-600 hover:text-green-800 text-sm">
                                    ✓ 完了にする
                                </button>
                            </form>
                        @endif

                        @if($task->user_id == auth()->id())
                            <a href="{{ route('tasks.edit', $task) }}" class="text-blue-600 hover:text-blue-800 text-sm">
                                編集
                            </a>
                        @endif

                        @if($task->user_id == auth()->id() || auth()->user()->isAdmin())
                            <form method="POST" action="{{ route('tasks.destroy', $task) }}" class="inline"
                                  onsubmit="return confirm('本当に削除しますか?')">
                                @csrf
                                @method('DELETE')
                                <button type="submit" class="text-red-600 hover:text-red-800 text-sm">
                                    削除
                                </button>
                            </form>
                        @endif
                    </div>
                </div>
            @endforeach
        </div>

        {{-- ページネーション --}}
        <div class="mt-6">
            {{ $tasks->links() }}
        </div>
    @else
        <div class="bg-white rounded-lg shadow p-8 text-center">
            <p class="text-gray-600">タスクがありません。新しいタスクを作成しましょう!</p>
        </div>
    @endif
@endsection

✅ タスク一覧ビューの作成が完了しました!次のページに続きます...

ステップ8: 管理者用ミドルウェア

IsAdminミドルウェアの作成

php artisan make:middleware IsAdmin
// app/Http/Middleware/IsAdmin.php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;

class IsAdmin
{
    public function handle(Request $request, Closure $next)
    {
        if (!auth()->check()) {
            return redirect()->route('login');
        }

        if (!auth()->user()->isAdmin()) {
            abort(403, 'このページにアクセスする権限がありません。');
        }

        return $next($request);
    }
}

ミドルウェアの登録

// bootstrap/app.php

use App\Http\Middleware\IsAdmin;

return Application::configure(basePath: dirname(__DIR__))
    ->withMiddleware(function (Middleware $middleware) {
        $middleware->alias([
            'admin' => IsAdmin::class,
        ]);
    })
    ->create();

管理者専用ルート

// routes/web.php

use App\Http\Controllers\Admin\DashboardController;

// 管理者専用ルート
Route::middleware(['auth', 'admin'])->prefix('admin')->name('admin.')->group(function () {
    Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard');
    Route::get('/users', [DashboardController::class, 'users'])->name('users');
});

管理者用コントローラーの作成

mkdir -p app/Http/Controllers/Admin
touch app/Http/Controllers/Admin/DashboardController.php
// app/Http/Controllers/Admin/DashboardController.php

namespace App\Http\Controllers\Admin;

use App\Http\Controllers\Controller;
use App\Models\User;
use App\Models\Task;

class DashboardController extends Controller
{
    public function index()
    {
        $totalUsers = User::count();
        $totalTasks = Task::count();
        $completedTasks = Task::where('status', 'completed')->count();
        $pendingTasks = Task::where('status', 'pending')->count();

        return view('admin.dashboard', compact('totalUsers', 'totalTasks', 'completedTasks', 'pendingTasks'));
    }

    public function users()
    {
        $users = User::withCount('tasks')->latest()->get();
        return view('admin.users', compact('users'));
    }
}

管理者用ビューの作成

mkdir -p resources/views/admin
touch resources/views/admin/dashboard.blade.php
touch resources/views/admin/users.blade.php
管理者ダッシュボード
{{-- resources/views/admin/dashboard.blade.php --}}

@extends('layouts.app')

@section('title', '管理者ダッシュボード')

@section('content')
<div class="container mx-auto px-4 py-8">
    <h1 class="text-3xl font-bold mb-8">管理者ダッシュボード</h1>

    <div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
        <div class="bg-white p-6 rounded-lg shadow">
            <h3 class="text-gray-600 text-sm">総ユーザー数</h3>
            <p class="text-3xl font-bold text-blue-600">{{ $totalUsers }}</p>
        </div>
        <div class="bg-white p-6 rounded-lg shadow">
            <h3 class="text-gray-600 text-sm">総タスク数</h3>
            <p class="text-3xl font-bold text-green-600">{{ $totalTasks }}</p>
        </div>
        <div class="bg-white p-6 rounded-lg shadow">
            <h3 class="text-gray-600 text-sm">完了タスク</h3>
            <p class="text-3xl font-bold text-purple-600">{{ $completedTasks }}</p>
        </div>
        <div class="bg-white p-6 rounded-lg shadow">
            <h3 class="text-gray-600 text-sm">未着手タスク</h3>
            <p class="text-3xl font-bold text-orange-600">{{ $pendingTasks }}</p>
        </div>
    </div>

    <div class="bg-white p-6 rounded-lg shadow">
        <h2 class="text-xl font-bold mb-4">管理メニュー</h2>
        <a href="{{ route('admin.users') }}" class="text-blue-600 hover:underline">
            ユーザー一覧を見る →
        </a>
    </div>
</div>
@endsection
ユーザー一覧ビュー
{{-- resources/views/admin/users.blade.php --}}

@extends('layouts.app')

@section('title', 'ユーザー一覧')

@section('content')
<div class="container mx-auto px-4 py-8">
    <div class="flex justify-between items-center mb-8">
        <h1 class="text-3xl font-bold">ユーザー一覧</h1>
        <a href="{{ route('admin.dashboard') }}" class="text-blue-600 hover:underline">
            ← ダッシュボードへ戻る
        </a>
    </div>

    <div class="bg-white rounded-lg shadow overflow-hidden">
        <table class="min-w-full">
            <thead class="bg-gray-50">
                <tr>
                    <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">ID</th>
                    <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">名前</th>
                    <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">メールアドレス</th>
                    <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">権限</th>
                    <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">タスク数</th>
                    <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">登録日</th>
                </tr>
            </thead>
            <tbody class="bg-white divide-y divide-gray-200">
                @foreach ($users as $user)
                <tr>
                    <td class="px-6 py-4 whitespace-nowrap">{{ $user->id }}</td>
                    <td class="px-6 py-4 whitespace-nowrap">{{ $user->name }}</td>
                    <td class="px-6 py-4 whitespace-nowrap">{{ $user->email }}</td>
                    <td class="px-6 py-4 whitespace-nowrap">
                        <span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full
                            {{ $user->role === 'admin' ? 'bg-red-100 text-red-800' : 'bg-green-100 text-green-800' }}">
                            {{ $user->role === 'admin' ? '管理者' : '一般' }}
                        </span>
                    </td>
                    <td class="px-6 py-4 whitespace-nowrap">{{ $user->tasks_count }}</td>
                    <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
                        {{ $user->created_at->format('Y/m/d') }}
                    </td>
                </tr>
                @endforeach
            </tbody>
        </table>
    </div>
</div>
@endsection

✅ 管理者用ダッシュボードとユーザー一覧機能が完成しました!

ステップ9: タスク作成・編集ビュー

タスク作成フォーム(画像アップロード付き)

{{-- resources/views/tasks/create.blade.php --}}

@extends('layouts.app')

@section('title', 'タスク作成')

@section('content')
    <div class="max-w-2xl mx-auto">
        <h1 class="text-2xl font-bold text-gray-800 mb-6">新規タスク作成</h1>

        @if($errors->any())
            <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
                <ul>
                    @foreach($errors->all() as $error)
                        <li>{{ $error }}</li>
                    @endforeach
                </ul>
            </div>
        @endif

        <form method="POST" action="{{ route('tasks.store') }}" enctype="multipart/form-data"
              class="bg-white rounded-lg shadow p-6">
            @csrf

            <div class="mb-4">
                <label class="block text-gray-700 text-sm font-bold mb-2">
                    タイトル <span class="text-red-500">*</span>
                </label>
                <input type="text" name="title" value="{{ old('title') }}"
                       class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
                       required>
            </div>

            <div class="mb-4">
                <label class="block text-gray-700 text-sm font-bold mb-2">
                    説明
                </label>
                <textarea name="description" rows="4"
                          class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500">{{ old('description') }}</textarea>
            </div>

            <div class="mb-4">
                <label class="block text-gray-700 text-sm font-bold mb-2">
                    優先度 <span class="text-red-500">*</span>
                </label>
                <select name="priority"
                        class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
                        required>
                    <option value="low" {{ old('priority') == 'low' ? 'selected' : '' }}>低</option>
                    <option value="medium" {{ old('priority', 'medium') == 'medium' ? 'selected' : '' }}>中</option>
                    <option value="high" {{ old('priority') == 'high' ? 'selected' : '' }}>高</option>
                </select>
            </div>

            <div class="mb-4">
                <label class="block text-gray-700 text-sm font-bold mb-2">
                    期限
                </label>
                <input type="date" name="due_date" value="{{ old('due_date') }}"
                       min="{{ date('Y-m-d') }}"
                       class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500">
            </div>

            <div class="mb-6">
                <label class="block text-gray-700 text-sm font-bold mb-2">
                    画像(任意)
                </label>
                <input type="file" name="image" accept="image/*"
                       class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500">
                <p class="text-gray-500 text-xs mt-1">
                    JPEG, PNG, GIF形式(最大2MB)
                </p>
            </div>

            <div class="flex gap-4">
                <button type="submit"
                        class="bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700 transition">
                    作成
                </button>
                <a href="{{ route('tasks.index') }}"
                   class="bg-gray-300 text-gray-700 px-6 py-2 rounded hover:bg-gray-400 transition">
                    キャンセル
                </a>
            </div>
        </form>
    </div>
@endsection

タスク編集フォーム

{{-- resources/views/tasks/edit.blade.php --}}

@extends('layouts.app')

@section('title', 'タスク編集')

@section('content')
    <div class="max-w-2xl mx-auto">
        <h1 class="text-2xl font-bold text-gray-800 mb-6">タスク編集</h1>

        @if($errors->any())
            <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
                <ul>
                    @foreach($errors->all() as $error)
                        <li>{{ $error }}</li>
                    @endforeach
                </ul>
            </div>
        @endif

        {{-- 現在の画像表示と削除ボタン --}}
        @if($task->image)
            <div class="bg-white rounded-lg shadow p-6 mb-6">
                <h2 class="text-lg font-bold text-gray-800 mb-4">現在の画像</h2>
                <img src="{{ $task->getImageUrl() }}" alt="現在の画像" class="w-48 h-48 object-cover rounded mb-4">
                <form method="POST" action="{{ route('tasks.delete-image', $task) }}"
                      onsubmit="return confirm('画像を削除しますか?')">
                    @csrf
                    @method('DELETE')
                    <button type="submit" class="bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700 transition">
                        画像を削除
                    </button>
                </form>
            </div>
        @endif

        {{-- タスク編集フォーム --}}
        <form method="POST" action="{{ route('tasks.update', $task) }}" enctype="multipart/form-data"
              class="bg-white rounded-lg shadow p-6">
            @csrf
            @method('PUT')

            <div class="mb-4">
                <label class="block text-gray-700 text-sm font-bold mb-2">
                    タイトル <span class="text-red-500">*</span>
                </label>
                <input type="text" name="title" value="{{ old('title', $task->title) }}"
                       class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
                       required>
            </div>

            <div class="mb-4">
                <label class="block text-gray-700 text-sm font-bold mb-2">
                    説明
                </label>
                <textarea name="description" rows="4"
                          class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500">{{ old('description', $task->description) }}</textarea>
            </div>

            <div class="mb-4">
                <label class="block text-gray-700 text-sm font-bold mb-2">
                    ステータス <span class="text-red-500">*</span>
                </label>
                <select name="status"
                        class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
                        required>
                    <option value="pending" {{ old('status', $task->status) == 'pending' ? 'selected' : '' }}>未着手</option>
                    <option value="in_progress" {{ old('status', $task->status) == 'in_progress' ? 'selected' : '' }}>進行中</option>
                    <option value="completed" {{ old('status', $task->status) == 'completed' ? 'selected' : '' }}>完了</option>
                </select>
            </div>

            <div class="mb-4">
                <label class="block text-gray-700 text-sm font-bold mb-2">
                    優先度 <span class="text-red-500">*</span>
                </label>
                <select name="priority"
                        class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
                        required>
                    <option value="low" {{ old('priority', $task->priority) == 'low' ? 'selected' : '' }}>低</option>
                    <option value="medium" {{ old('priority', $task->priority) == 'medium' ? 'selected' : '' }}>中</option>
                    <option value="high" {{ old('priority', $task->priority) == 'high' ? 'selected' : '' }}>高</option>
                </select>
            </div>

            <div class="mb-4">
                <label class="block text-gray-700 text-sm font-bold mb-2">
                    期限
                </label>
                <input type="date" name="due_date"
                       value="{{ old('due_date', $task->due_date ? $task->due_date->format('Y-m-d') : '') }}"
                       class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500">
            </div>

            <div class="mb-6">
                <label class="block text-gray-700 text-sm font-bold mb-2">
                    画像 {{ $task->image ? '(新しい画像に変更)' : '' }}
                </label>
                <input type="file" name="image" accept="image/*"
                       class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500">
                <p class="text-gray-500 text-xs mt-1">
                    {{ $task->image ? '新しい画像をアップロードすると、現在の画像が置き換わります。' : 'JPEG, PNG, GIF形式(最大2MB)' }}
                </p>
            </div>

            <div class="flex gap-4">
                <button type="submit"
                        class="bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700 transition">
                    更新
                </button>
                <a href="{{ route('tasks.index') }}"
                   class="bg-gray-300 text-gray-700 px-6 py-2 rounded hover:bg-gray-400 transition">
                    キャンセル
                </a>
            </div>
        </form>
    </div>
@endsection

タスク詳細ページ

{{-- resources/views/tasks/show.blade.php --}}

@extends('layouts.app')

@section('title', $task->title)

@section('content')
    <div class="max-w-3xl mx-auto">
        <div class="bg-white rounded-lg shadow p-8">
            {{-- ステータスバッジ --}}
            <div class="flex items-center gap-2 mb-4">
                <span class="px-3 py-1 text-sm rounded
                    {{ $task->getPriorityColor() == 'high' ? 'bg-red-100 text-red-800' : '' }}
                    {{ $task->getPriorityColor() == 'medium' ? 'bg-yellow-100 text-yellow-800' : '' }}
                    {{ $task->getPriorityColor() == 'low' ? 'bg-green-100 text-green-800' : '' }}">
                    優先度: {{ $task->getPriorityLabel() }}
                </span>
                <span class="px-3 py-1 text-sm rounded
                    {{ $task->getStatusColor() == 'completed' ? 'bg-green-100 text-green-800' : '' }}
                    {{ $task->getStatusColor() == 'in_progress' ? 'bg-blue-100 text-blue-800' : '' }}
                    {{ $task->getStatusColor() == 'pending' ? 'bg-gray-100 text-gray-800' : '' }}">
                    {{ $task->getStatusLabel() }}
                </span>
                @if($task->isOverdue())
                    <span class="px-3 py-1 text-sm rounded bg-red-600 text-white">期限切れ</span>
                @endif
            </div>

            {{-- タイトル --}}
            <h1 class="text-3xl font-bold text-gray-800 mb-4">{{ $task->title }}</h1>

            {{-- メタ情報 --}}
            <div class="text-sm text-gray-600 mb-6 space-y-1">
                <p>作成者: {{ $task->user->name }}</p>
                <p>作成日: {{ $task->created_at->format('Y年m月d日 H:i') }}</p>
                @if($task->due_date)
                    <p>期限: {{ $task->due_date->format('Y年m月d日') }}</p>
                @endif
                @if($task->completed_at)
                    <p>完了日時: {{ $task->completed_at->format('Y年m月d日 H:i') }}</p>
                @endif
            </div>

            {{-- 画像 --}}
            @if($task->image)
                <div class="mb-6">
                    <img src="{{ $task->getImageUrl() }}" alt="{{ $task->title }}"
                         class="w-full max-w-lg rounded-lg shadow">
                </div>
            @endif

            {{-- 説明 --}}
            @if($task->description)
                <div class="mb-6">
                    <h2 class="text-xl font-bold text-gray-800 mb-2">説明</h2>
                    <p class="text-gray-700 whitespace-pre-wrap">{{ $task->description }}</p>
                </div>
            @endif

            {{-- アクションボタン --}}
            <div class="flex gap-4 mt-8">
                @if($task->user_id == auth()->id() && !$task->isCompleted())
                    <form method="POST" action="{{ route('tasks.complete', $task) }}">
                        @csrf
                        <button type="submit"
                                class="bg-green-600 text-white px-6 py-2 rounded hover:bg-green-700 transition">
                            ✓ 完了にする
                        </button>
                    </form>
                @endif

                @if($task->user_id == auth()->id())
                    <a href="{{ route('tasks.edit', $task) }}"
                       class="bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700 transition">
                        編集
                    </a>
                @endif

                @if($task->user_id == auth()->id() || auth()->user()->isAdmin())
                    <form method="POST" action="{{ route('tasks.destroy', $task) }}" class="inline"
                          onsubmit="return confirm('本当に削除しますか?')">
                        @csrf
                        @method('DELETE')
                        <button type="submit"
                                class="bg-red-600 text-white px-6 py-2 rounded hover:bg-red-700 transition">
                            削除
                        </button>
                    </form>
                @endif

                <a href="{{ route('tasks.index') }}"
                   class="bg-gray-300 text-gray-700 px-6 py-2 rounded hover:bg-gray-400 transition">
                    一覧に戻る
                </a>
            </div>
        </div>
    </div>
@endsection

✅ 全てのビューファイルの作成が完了しました!

ステップ10: ルート設定の追加

routes/web.phpに画像削除ルートを追加

// routes/web.php

Route::middleware('auth')->group(function () {
    // タスク完了
    Route::post('/tasks/{task}/complete', [TaskController::class, 'complete'])
        ->name('tasks.complete');

    // 画像削除
    Route::delete('/tasks/{task}/delete-image', [TaskController::class, 'deleteImage'])
        ->name('tasks.delete-image');
});

✅ ルート設定が完了しました!

ステップ11: シーダーで初期データ作成

シーダーの作成

# シーダー作成
php artisan make:seeder UserSeeder
php artisan make:seeder TaskSeeder
// database/seeders/UserSeeder.php

namespace Database\Seeders;

use App\Models\User;
use Illuminate\Database\Seeder;

class UserSeeder extends Seeder
{
    public function run(): void
    {
        // 管理者ユーザー
        User::create([
            'name' => '管理者',
            'email' => 'admin@example.com',
            'password' => 'password',
            'role' => 'admin',
        ]);

        // 一般ユーザー
        User::create([
            'name' => '田中太郎',
            'email' => 'tanaka@example.com',
            'password' => 'password',
            'role' => 'user',
        ]);

        User::create([
            'name' => '佐藤花子',
            'email' => 'sato@example.com',
            'password' => 'password',
            'role' => 'user',
        ]);
    }
}
// database/seeders/TaskSeeder.php

namespace Database\Seeders;

use App\Models\Task;
use App\Models\User;
use Illuminate\Database\Seeder;

class TaskSeeder extends Seeder
{
    public function run(): void
    {
        $tanaka = User::where('email', 'tanaka@example.com')->first();
        $sato = User::where('email', 'sato@example.com')->first();

        // 田中太郎のタスク
        Task::create([
            'user_id' => $tanaka->id,
            'title' => 'Laravelの勉強',
            'description' => 'ルーティング、コントローラー、モデルを学ぶ',
            'status' => 'in_progress',
            'priority' => 'high',
            'due_date' => now()->addDays(3),
        ]);

        Task::create([
            'user_id' => $tanaka->id,
            'title' => 'TODOアプリの作成',
            'description' => '認証機能とCRUD機能を実装',
            'status' => 'pending',
            'priority' => 'medium',
            'due_date' => now()->addWeek(),
        ]);

        Task::create([
            'user_id' => $tanaka->id,
            'title' => 'データベース設計書を作成',
            'description' => 'ER図とテーブル定義書',
            'status' => 'completed',
            'priority' => 'low',
            'completed_at' => now()->subDays(2),
        ]);

        // 佐藤花子のタスク
        Task::create([
            'user_id' => $sato->id,
            'title' => 'プレゼン資料作成',
            'description' => '来週のミーティング用',
            'status' => 'pending',
            'priority' => 'high',
            'due_date' => now()->addDays(5),
        ]);

        Task::create([
            'user_id' => $sato->id,
            'title' => 'コードレビュー',
            'description' => 'プルリクエスト#123をレビュー',
            'status' => 'in_progress',
            'priority' => 'medium',
            'due_date' => now()->addDay(),
        ]);
    }
}
// database/seeders/DatabaseSeeder.php

namespace Database\Seeders;

use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    public function run(): void
    {
        $this->call([
            UserSeeder::class,
            TaskSeeder::class,
        ]);
    }
}
# シーダー実行
php artisan db:seed

# または、マイグレーション+シーダーを一度に実行
php artisan migrate:fresh --seed

✅ 初期データの作成が完了しました!

まとめ

TODOアプリで使った技術

1. データベース設計

  • マイグレーションでusers、tasksテーブル作成
  • 外部キー制約でリレーション定義
  • enum型でステータス・優先度管理

2. モデルとリレーション

  • User hasMany Task(1対多)
  • Task belongsTo User
  • カスタムメソッド(isCompleted、isOverdue、markAsCompleted)
  • Eager Loadingでn+1問題回避

3. 認証機能

  • ユーザー登録・ログイン・ログアウト
  • パスワードのハッシュ化
  • セッション管理
  • guestミドルウェアでログイン済みはリダイレクト

4. CRUD操作

  • リソースコントローラーで7つのアクション
  • index(一覧)、create(作成フォーム)、store(保存)
  • show(詳細)、edit(編集フォーム)、update(更新)、destroy(削除)
  • バリデーションで入力チェック

5. 権限管理

  • ロール(admin、user)でアクセス制御
  • ミドルウェアでルート単位の権限チェック
  • コントローラーで個別リソースの権限チェック
  • 自分のタスクのみ編集可能
  • 管理者は全タスク閲覧・削除可能

6. 画像アップロード

  • タスクに画像を添付可能
  • Storage::disk('public')で画像保存
  • 画像の削除・差し替え機能
  • バリデーションでファイル形式・サイズチェック

7. 検索・フィルタリング

  • ステータス・優先度でフィルタ
  • キーワード検索(タイトル・説明文)
  • 期限順・作成日順でソート
  • 完了タスクは下に表示

8. ページネーション

  • paginate(10)で1ページ10件
  • withQueryString()で検索条件を保持
  • ページリンクの自動生成

9. ビューファイル

  • Bladeテンプレートで全画面実装
  • レイアウトファイルで共通部分を再利用
  • 認証ページ(ログイン・登録)
  • タスクCRUD画面(一覧・作成・編集・詳細)
  • 画像アップロードフォーム実装

10. シーダー

  • シーダーで初期データ作成
  • データベースへのテストデータ投入

学んだLaravelの機能一覧

基礎

  • ルーティング
  • コントローラー
  • ビュー(Blade)
  • マイグレーション
  • モデル

データベース

  • Eloquent ORM
  • リレーション
  • クエリビルダー
  • シーダー

認証・権限

  • ユーザー認証
  • ミドルウェア
  • ロール管理
  • コントローラーでの権限チェック

応用

  • ページネーション
  • 検索・フィルタリング
  • バリデーション
  • 画像アップロード
  • ファイルストレージ
  • フラッシュメッセージ
  • リダイレクト

🎉 総合実践TODOアプリが完成しました!

これまで学んだ全ての知識を使って、実際に動作する実践的なTODOアプリケーションを作成できました。
認証、CRUD、ロール管理、リレーション、ページネーション、検索、画像アップロードなど、実務で必要な機能を全て実装しました。
全てのビューファイル(Blade)も用意されているので、すぐに動かして試すことができます!