Laravel 13でCRUDを実装する方法|タスク管理アプリで登録・編集・削除を学ぶ

はじめに

Laravelをインストールして初期画面を表示できたら、次はデータベースを使ったアプリを作ってみましょう。

この記事では、Laravel 13・SQLite・Bladeを使い、タスクの登録・一覧表示・編集・削除ができる小さなアプリを作成します。

モデル、マイグレーション、コントローラー、ルーティング、画面の役割を、実装しながら確認できる構成です。

LaravelのCRUDとは?

CRUDとは、データを扱う基本的な4つの操作です。

操作意味今回作る機能
Create作成タスクを登録する
Read読み取り保存したタスクを一覧表示する
Update更新タスク名と完了状態を変更する
Delete削除不要なタスクを削除する

問い合わせ管理、顧客管理、在庫管理なども、基本はこうしたデータ操作の組み合わせです。

今回は、登録したタスクがページを再読み込みしても残るよう、SQLiteに保存します。

今回の前提と完成するアプリ

前回の導入記事で作成したLaravelプロジェクトを使います。

Laravel 13をWSL2にインストールする方法|Windowsで環境構築から起動まで
WindowsのWSL2・UbuntuでLaravel 13の開発環境を構築する手順を解説。PHP・Composerの導入、Node.jsの準備、新規プロジェクト作成、ブラウザでの起動確認、簡単な一覧画面の作成まで紹介します。
項目今回の構成
開発環境Windows・WSL2・Ubuntu
Laravel13系
データベースSQLite
画面Blade
入力項目タスク名・完了状態
一覧画面/tasks
編集画面/tasks/{id}/edit

コマンドは、WSLのUbuntuターミナルから、Laravelプロジェクトのルートで実行します。artisanやcomposer.jsonがあるフォルダーです。

php artisan --version

Laravel 13系であることを確認してください。

本記事はローカル学習用のサンプルです。ログインやユーザー別のアクセス制御は含めていないため、公開サービスにする場合は認証・認可を追加してください。

1.SQLiteの設定を確認する

プロジェクトの.envを開き、SQLiteを使用する設定を確認します。

DB_CONNECTION=sqlite

DB_DATABASEを独自に設定している場合は、その接続先も確認してください。以下では、標準のdatabase/database.sqliteを使う前提で進めます。

ファイルが存在しない場合に備えて、Ubuntuターミナルで次を実行します。

touch database/database.sqlite
php artisan config:clear

touchは、ファイルが存在しなければ作成するコマンドです。すでにあるデータベースの内容を空にする操作ではありません。

2.Taskモデルとマイグレーションを作成する

次のコマンドを実行します。

php artisan make:model Task -m

主に次の2つが作成されます。

ファイル役割
app/Models/Task.phpタスクのデータを扱うモデル
database/migrations/日時_create_tasks_table.phptasksテーブルを作るための定義

マイグレーションは、テーブル構造をコードとして管理する仕組みです。Laravel公式:マイグレーション

tasksテーブルの項目を定義する

生成された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->string('title', 255);
            $table->boolean('is_completed')->default(false);
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('tasks');
    }
};

各カラムの役割は次のとおりです。

カラム用途
idタスクを識別する番号
titleタスク名
is_completed完了・未完了
created_at作成日時
updated_at更新日時

マイグレーションを実行する

php artisan migrate

実行状況は次で確認できます。

php artisan migrate:status

対象のマイグレーションがRanになっていることを確認します。

すでに実行したマイグレーションを書き換えても、通常のmigrateでは再実行されません。後から項目を変更する場合は、変更用のマイグレーションを追加します。

3.Taskモデルを設定する

app/Models/Task.phpを次の内容にします。

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Task extends Model
{
    protected function casts(): array
    {
        return [
            'is_completed' => 'boolean',
        ];
    }
}

is_completedを真偽値として扱う設定です。

LaravelのEloquentでは、通常、Taskモデルがtasksテーブルに対応します。今回はコントローラーで保存する項目を一つずつ代入するため、$fillableの設定は使用しません。Laravel公式:Eloquent

4.CRUDを処理するコントローラーを作成する

次を実行します。

php artisan make:controller TaskController --resource --model=Task

生成されたapp/Http/Controllers/TaskController.phpを、次の内容に置き換えます。

<?php

namespace App\Http\Controllers;

use App\Models\Task;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;

class TaskController extends Controller
{
    public function index(): View
    {
        return view('tasks.index', [
            'tasks' => Task::orderByDesc('id')->get(),
        ]);
    }

    public function store(Request $request): RedirectResponse
    {
        $data = $this->validatedData($request);

        $task = new Task();
        $task->title = $data['title'];
        $task->is_completed = $request->boolean('is_completed');
        $task->save();

        return redirect()
            ->route('tasks.index')
            ->with('status', 'タスクを登録しました。');
    }

    public function edit(Task $task): View
    {
        return view('tasks.edit', [
            'task' => $task,
        ]);
    }

    public function update(
        Request $request,
        Task $task
    ): RedirectResponse {
        $data = $this->validatedData($request);

        $task->title = $data['title'];
        $task->is_completed = $request->boolean('is_completed');
        $task->save();

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

    public function destroy(Task $task): RedirectResponse
    {
        $task->delete();

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

    private function validatedData(Request $request): array
    {
        return $request->validate(
            [
                'title' => ['required', 'string', 'max:255'],
                'is_completed' => ['required', 'boolean'],
            ],
            [
                'title.required' => 'タスク名を入力してください。',
                'title.string' => 'タスク名は文字列で入力してください。',
                'title.max' => 'タスク名は255文字以内で入力してください。',
                'is_completed.required' => '完了状態を選択してください。',
                'is_completed.boolean' => '完了状態の値が不正です。',
            ]
        );
    }
}

各メソッドの役割

メソッド処理
index一覧画面を表示する
store新しいタスクを保存する
edit編集画面を表示する
updateタスクを更新する
destroyタスクを削除する

今回は登録フォームを一覧画面に置くため、登録専用画面のcreateは使いません。詳細画面のshowも省いています。

CRUDに対応した処理をまとめる方法として、Laravelにはリソースコントローラーが用意されています。Laravel公式:リソースコントローラー

バリデーションで不正な入力を防ぐ

validatedData()では、タスク名の必須入力と文字数、完了状態の値を確認しています。

検証に失敗すると、通常のWebフォームでは前の画面へ戻り、エラーと入力内容がセッションに保存されます。画面側では、後述する@errorとold()で利用します。Laravel公式:バリデーション

5.CRUDのルーティングを設定する

routes/web.phpに、次の読み込みとルートを追加します。

use App\Http\Controllers\TaskController;
use Illuminate\Support\Facades\Route;

Route::resource('tasks', TaskController::class)
    ->only(['index', 'store', 'edit', 'update', 'destroy']);

Routeの読み込みがすでにある場合は、重複して追加しないでください。既存のトップページ用ルートなどは、そのままで構いません。

導入記事で仮の配列を表示するRoute::get('/tasks', ...)を作っている場合は、その定義を今回のリソースルートへ置き換えます。

登録されたルートを確認します。

php artisan route:list --path=tasks

主な対応は次のとおりです。

HTTPメソッドURL処理ルート名
GET/tasks一覧表示tasks.index
POST/tasks登録tasks.store
GET/tasks/{task}/edit編集画面tasks.edit
PUT/PATCH/tasks/{task}更新tasks.update
DELETE/tasks/{task}削除tasks.destroy

Task $taskは、URLに含まれる番号に対応するタスクを受け取ります。これをルートモデルバインディングと呼び、存在しないタスクへのアクセスは通常404になります。Laravel公式:ルートモデルバインディング

6.Bladeで登録・一覧・編集画面を作る

今回は次の4ファイルを作成します。

resources/views/
├── layouts/
│   └── app.blade.php
└── tasks/
    ├── _form.blade.php
    ├── index.blade.php
    └── edit.blade.php

フォルダーは次のコマンドで作成できます。

mkdir -p resources/views/layouts resources/views/tasks

導入記事のresources/views/tasks.blade.phpと、今回のtasks/index.blade.phpは別のビューです。今回のコントローラーは、新しく作るtasksフォルダー内のビューを使用します。

共通レイアウト

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">
    <title>@yield('title', 'タスク管理')</title>
    <style>
        body {
            max-width: 800px;
            margin: 40px auto;
            padding: 0 16px;
            font-family: sans-serif;
            line-height: 1.8;
        }

        input[type="text"] {
            box-sizing: border-box;
            width: 100%;
            padding: 8px;
        }

        button, select {
            padding: 6px 12px;
        }

        table {
            width: 100%;
            border-collapse: collapse;
            margin-top: 24px;
        }

        th, td {
            padding: 12px;
            border-bottom: 1px solid #ddd;
            text-align: left;
            overflow-wrap: anywhere;
        }

        .error {
            color: #b91c1c;
        }

        .notice {
            padding: 12px;
            background: #ecfdf5;
        }

        .inline {
            display: inline;
        }
    </style>
</head>
<body>
    <main>
        @if (session('status'))
            <p class="notice" role="status">
                {{ session('status') }}
            </p>
        @endif

        @yield('content')
    </main>
</body>
</html>

登録・編集で共通の入力欄

resources/views/tasks/_form.blade.phpを作成します。

<p>
    <label for="title">タスク名</label>
    <input
        type="text"
        id="title"
        name="title"
        value="{{ old('title', $task->title ?? '') }}"
        maxlength="255"
        required
    >
</p>

@error('title')
    <p class="error" role="alert">{{ $message }}</p>
@enderror

<p>
    <label for="is_completed">状態</label>
    <select id="is_completed" name="is_completed">
        <option
            value="0"
            @selected(
                (string) old(
                    'is_completed',
                    (int) ($task->is_completed ?? false)
                ) === '0'
            )
        >
            未完了
        </option>
        <option
            value="1"
            @selected(
                (string) old(
                    'is_completed',
                    (int) ($task->is_completed ?? false)
                ) === '1'
            )
        >
            完了
        </option>
    </select>
</p>

@error('is_completed')
    <p class="error" role="alert">{{ $message }}</p>
@enderror

old()は、入力エラーで戻ったときに以前の入力を表示するために使います。編集画面では、以前の入力がなければ保存済みの値を表示します。

登録フォームと一覧画面

resources/views/tasks/index.blade.phpを作成します。

@extends('layouts.app')

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

@section('content')
    <h1>タスク一覧</h1>

    <h2>タスクを登録する</h2>

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

        @include('tasks._form', ['task' => null])

        <button type="submit">登録する</button>
    </form>

    <h2>登録済みのタスク</h2>

    <table>
        <thead>
            <tr>
                <th scope="col">タスク名</th>
                <th scope="col">状態</th>
                <th scope="col">操作</th>
            </tr>
        </thead>
        <tbody>
            @forelse ($tasks as $task)
                <tr>
                    <td>{{ $task->title }}</td>
                    <td>
                        {{ $task->is_completed ? '完了' : '未完了' }}
                    </td>
                    <td>
                        <a href="{{ route('tasks.edit', $task) }}">
                            編集
                        </a>

                        <form
                            class="inline"
                            action="{{ route('tasks.destroy', $task) }}"
                            method="POST"
                            onsubmit="return confirm('このタスクを削除しますか?');"
                        >
                            @csrf
                            @method('DELETE')

                            <button type="submit">削除</button>
                        </form>
                    </td>
                </tr>
            @empty
                <tr>
                    <td colspan="3">
                        まだタスクがありません。
                    </td>
                </tr>
            @endforelse
        </tbody>
    </table>
@endsection

編集画面

resources/views/tasks/edit.blade.phpを作成します。

@extends('layouts.app')

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

@section('content')
    <h1>タスクを編集する</h1>

    <form
        action="{{ route('tasks.update', $task) }}"
        method="POST"
    >
        @csrf
        @method('PUT')

        @include('tasks._form', ['task' => $task])

        <button type="submit">更新する</button>
        <a href="{{ route('tasks.index') }}">一覧へ戻る</a>
    </form>
@endsection

@csrfと@methodの役割

@csrfは、フォームにCSRF対策用のトークンを入れます。

HTMLフォームからPUTやDELETEを扱うため、Laravelでは@method('PUT')などで送信する操作を指定します。フォーム自体のmethodはPOSTにします。

また、{{ $task->title }}はHTMLをエスケープして表示します。ユーザーが入力した文字列の表示には、この形式を使用します。Laravel公式:Bladeテンプレート

7.LaravelのCRUDを動作確認する

今回の画面は外部のCSS・JavaScriptに依存しないため、PHPの開発サーバーで確認できます。

php artisan serve

すでにcomposer run devで起動している場合は、そのまま使えます。同じポートで重複起動する必要はありません。

Windows側のブラウザでタスク一覧画面を開きます。

登録・一覧表示を確認する

  1. タスク名に「LaravelでCRUDを作る」と入力する
  2. 状態を「未完了」にする
  3. 「登録する」を押す
  4. 一覧に表示されることを確認する
  5. ページを再読み込みしても残っていることを確認する

登録後は一覧へリダイレクトするため、表示された一覧を再読み込みしても同じ登録処理を繰り返しません。

編集・更新を確認する

  1. 登録したタスクの「編集」を押す
  2. タスク名を変更する
  3. 状態を「完了」にする
  4. 「更新する」を押す
  5. 一覧へ戻り、名前と状態が変わっていることを確認する

入力エラーを確認する

タスク名を空にして登録しようとすると、通常はブラウザの必須入力チェックが働きます。

サーバー側の検証も確認する場合は、タスク名に半角スペースだけを入力して送信します。標準構成のLaravelでは前後の空白が除去され、必須入力エラーになることを確認します。

削除を確認する

  1. 「削除」を押す
  2. 確認ダイアログでキャンセルし、タスクが残ることを確認する
  3. 再び「削除」を押し、確定する
  4. 一覧から消え、再読み込みしても戻らないことを確認する

今回の削除は、データベースからレコードを削除する処理です。ごみ箱や復元機能は実装していません。

LaravelのCRUDでよくあるエラー

エラー・症状確認すること
no such table: tasksphp artisan migrateを実行したか、接続先のSQLiteが正しいか
could not find driver実行しているPHPにSQLite用のPDOドライバーがあるか
View [tasks.index] not foundresources/views/tasks/index.blade.phpの場所と名前
Route [tasks.store] not definedリソースルートの設定とroute:listの結果
419 Page Expired@csrf、Cookie、セッション、ページを開き直して改善するか
更新・削除が405になるフォームのPOSTと@methodが正しいか
編集画面が404になる指定したタスクが存在するか、すでに削除していないか
導入記事の仮データが表示される古いRoute::get('/tasks', ...)が残っていないか

ルーティングを修正しても反映されない場合は、キャッシュを解除して確認します。

php artisan route:clear
php artisan route:list --path=tasks

.envの変更後は、設定キャッシュも確認します。

php artisan config:clear

データを残したまま調べたい場合は、テーブルを作り直すmigrate:freshを実行せず、まず接続先とマイグレーション状況を確認してください。

次に追加したい機能

CRUDができたら、次の順に機能を追加すると、実際の業務アプリに近づけられます。

  • ページネーション:タスクを一定件数ずつ表示する
  • 検索・絞り込み:タスク名や完了状態で探す
  • 入力処理の整理:Form Requestにバリデーションを分ける
  • ログインと認可:自分のタスクだけ操作できるようにする
  • 機能テスト:登録・更新・削除・入力エラーを自動確認する

今回の画面はBladeですが、画面の操作性を発展させたい場合は、LaravelでInertia.jsとReactを使うメリットも参考にしてください。

データベースとCRUDの流れを理解しておくと、Reactを使う構成へ進んだときも、Laravel側で必要になる処理を整理しやすくなります。