公開日 2026-03-25

Laravelで最小CRUDを作る(一覧 / 作成 / 編集 / 削除)

Laravel 13 で本管理の最小CRUDを作り、一覧 / 作成 / 編集 / 削除と validation の戻り方を SQLite 付きで一通りつなげる。

目次

  1. 前提環境
  2. 1. ゴールと非対象
  3. 2. 新規デモを作成して Laravel を起動する
  4. 3. Model と migration を作る
  5. コードのポイント
  6. 4. Route と Controller で CRUD の流れを作る
  7. コードのポイント
  8. 5. Blade を最小分割して一覧 / 作成 / 編集画面を作る
  9. コードのポイント
  10. 6. migration を実行して create を確認する
  11. 7. 編集と削除を確認する
  12. 8. よくある詰まりと次の一歩

Laravel入門 は通したものの、一覧 / 作成 / 編集 / 削除を 1 本でつなぐところで止まりやすい読者向けです。
ゴールは、SQLite を使った Book 管理の最小 CRUD を作り、validation と redirect の戻り方まで確認すること。

この 3 本は 最小CRUD -> Breeze認証 -> Policy認可 の順でつながります。
この記事はその起点で、次の Laravelで認証を足す(Breeze 最小導入)Laravelで認可を入れる(Policyで自分の本だけ編集できるようにする) の前提記事です。

導入前に補助として参照しやすい記事:

前提環境

  • Windows 11
  • WSL2(Ubuntu)
  • VS Code(Remote - WSL)
  • Docker Desktop(WSL 連携有効)

以降のコマンドは、特記がない限り WSL 側ターミナルで実行します。

1. ゴールと非対象

この記事で到達する状態:

  • Book 一覧画面を表示できる
  • 本の追加、編集、削除をブラウザで確認できる
  • バリデーションエラー時にフォームへ戻り、入力値とエラー表示が残ることを確認できる

今回は、最小の画面 CRUD を 1 本通すことに絞ります。
認証や Form Request を同時に入れると、Controller と Blade の基本導線が見えにくくなります。

非対象:

  • 認証、認可
  • Form Request の本格導入
  • FactorySeeder を使ったデータ準備
  • PostgreSQL / MySQL 前提の運用
  • テスト、CI、デプロイ

2. 新規デモを作成して Laravel を起動する

最初に確認するのは、WSL と Docker が見えているかどうか。

PowerShell:

wsl -l -v

WSL(Ubuntu):

docker --version
docker compose version

開始位置は次のディレクトリです。

mkdir -p ~/projects/laravel-minimal-crud-demo
cd ~/projects/laravel-minimal-crud-demo
code .

プロジェクト作成は composer:2 コンテナから実行します。
ホスト側へ PHP や Composer を直接入れずに済みます。

docker run --rm -u "$(id -u):$(id -g)" -v "$(pwd):/app" -w /app composer:2 create-project laravel/laravel .

-u "$(id -u):$(id -g)" を付けると、生成ファイルの所有者が WSL 側の自分になります。
ここを外すと Laravel 一式が root 所有になり、VS Code で保存できずに止まりやすくなります。

次の内容で compose.yml を作成してください。

services:
  app:
    image: composer:2
    user: "${LOCAL_UID:-1000}:${LOCAL_GID:-1000}"
    working_dir: /app
    volumes:
      - ./:/app
    ports:
      - "8000:8000"
    command: php artisan serve --host=0.0.0.0 --port=8000

同じシェルで次も実行して、docker compose 側でも自分の UID / GID を使うようにします。

export LOCAL_UID="$(id -u)"
export LOCAL_GID="$(id -g)"

起動します。

docker compose up -d
docker compose logs app --tail 20

ブラウザで http://localhost:8000 を開き、Laravel の初期 welcome 画面が見えれば準備完了です。

8000 番ポートが埋まっている場合は、compose.yml の左側を 8001:8000 に変えてから docker compose up -d をやり直してください。

3. Model と migration を作る

まずは Book モデルと books テーブルを作ります。

docker compose exec app php artisan make:model Book -m

生成された migration の timestamp 部分は各自で異なります。
*_create_books_table.php を次の内容に置き換えてください。

database/migrations/*_create_books_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::create('books', function (Blueprint $table) {
            $table->id();
            $table->string('title');
            $table->string('author');
            $table->unsignedInteger('price');
            $table->timestamps();
        });
    }

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

app/Models/Book.php:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Book extends Model
{
    protected $fillable = [
        'title',
        'author',
        'price',
    ];
}

価格を unsignedInteger にして、最初の CRUD に decimal や金額計算の話を持ち込まないようにしています。 ここで先に押さえるのは「保存できる」「更新できる」という基本動作です。

コードのポイント

$fillable による一括代入の許可

app/Models/Book.php のポイントはここ。

protected $fillable = [
    'title',
    'author',
    'price',
];

$fillable に列名を列挙しないと、Book::query()->create($validated) は何も保存せずに終わる。 Eloquent はデフォルトで一括代入を拒否するため、許可する列を明示する必要がある。

4. Route と Controller で CRUD の流れを作る

次は Controller の追加。

docker compose exec app php artisan make:controller BookController

routes/web.php:

<?php

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

Route::redirect('/', '/books');
Route::resource('books', BookController::class)->except(['show']);

app/Http/Controllers/BookController.php:

Book に関する HTTP リクエストを受け取り、validation → DB 操作 → redirect の一連を担う。$request->validate() と Route モデルバインディングがこの Controller の核心。

<?php

namespace App\Http\Controllers;

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

class BookController extends Controller
{
    public function index(): View
    {
        return view('books.index', [
            'books' => Book::query()
                ->orderByDesc('id')
                ->get(),
        ]);
    }

    public function create(): View
    {
        return view('books.create');
    }

    public function store(Request $request): RedirectResponse
    {
        $validated = $request->validate([
            'title' => ['required', 'string', 'max:255'],
            'author' => ['required', 'string', 'max:255'],
            'price' => ['required', 'integer', 'min:0'],
        ]);

        Book::query()->create($validated);

        return redirect()
            ->route('books.index')
            ->with('status', '本を追加しました。');
    }

    public function edit(Book $book): View
    {
        return view('books.edit', [
            'book' => $book,
        ]);
    }

    public function update(Request $request, Book $book): RedirectResponse
    {
        $validated = $request->validate([
            'title' => ['required', 'string', 'max:255'],
            'author' => ['required', 'string', 'max:255'],
            'price' => ['required', 'integer', 'min:0'],
        ]);

        $book->update($validated);

        return redirect()
            ->route('books.index')
            ->with('status', '本を更新しました。');
    }

    public function destroy(Book $book): RedirectResponse
    {
        $book->delete();

        return redirect()
            ->route('books.index')
            ->with('status', '本を削除しました。');
    }
}

Route::resource() を使うと、CRUD 用の URL と route 名をまとめて作れます。 .except(['show']) は個別の詳細画面(/books/{id})を今回は作らないため除いています。 validation は $request->validate() に絞り、失敗時の戻り方は 6 章で確認します。

コードのポイント

Route::resource.except() でルートをまとめて定義する

routes/web.php のポイントはここ。

Route::resource('books', BookController::class)->except(['show']);

Route::resource() 1行で index / create / store / edit / update / destroy の6ルートを生成する。 ->except(['show']) を連結すると、今回作らない個別表示ルートだけを除外できる。

$request->validate() と失敗時の自動リダイレクト

BookController::store() のポイントはここ。

$validated = $request->validate([
    'title' => ['required', 'string', 'max:255'],
    'author' => ['required', 'string', 'max:255'],
    'price' => ['required', 'integer', 'min:0'],
]);

validate() が失敗すると自動で直前ページへリダイレクトし、エラーと入力値をセッションへフラッシュする。 成功した場合のみ $validated にデータが入り、次の Book::query()->create() へ進む。

③ ルートモデルバインディングで自動フェッチ

BookController::edit() のポイントはここ。

public function edit(Book $book): View
{
    return view('books.edit', [
        'book' => $book,
    ]);
}

引数に Book $book と型宣言するだけで、{book} パラメータから自動的にレコードを取得する。 存在しない ID には自動で 404 を返すため、Controller 側での存在確認は不要。

5. Blade を最小分割して一覧 / 作成 / 編集画面を作る

今回は 5 ファイル構成です。すべて自分で作成します。

  • layouts/app.blade.php(自分で作成)
  • books/_form.blade.php(自分で作成)
  • books/index.blade.php(自分で作成)
  • books/create.blade.php(自分で作成)
  • books/edit.blade.php(自分で作成)

resources/views/ 直下には welcome.blade.php しかないので、先にディレクトリとファイルを用意します。

mkdir -p resources/views/layouts resources/views/books
touch resources/views/layouts/app.blade.php
touch resources/views/books/_form.blade.php
touch resources/views/books/index.blade.php
touch resources/views/books/create.blade.php
touch resources/views/books/edit.blade.php

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', 'Laravel CRUD Demo')</title>
    <style>
        body {
            margin: 0;
            font-family: "Segoe UI", sans-serif;
            background: #f5f7fb;
            color: #1f2937;
        }

        .container {
            max-width: 920px;
            margin: 40px auto;
            padding: 0 20px 40px;
        }

        .card {
            background: #ffffff;
            border-radius: 16px;
            box-shadow: 0 18px 48px rgba(15, 23, 42, 0.08);
            padding: 24px;
        }

        .stack > * + * {
            margin-top: 16px;
        }

        .actions {
            display: flex;
            gap: 12px;
            flex-wrap: wrap;
        }

        .button,
        button {
            border: 0;
            border-radius: 999px;
            padding: 10px 18px;
            background: #0f766e;
            color: #ffffff;
            cursor: pointer;
            text-decoration: none;
            font: inherit;
        }

        .button.subtle,
        button.subtle {
            background: #e5e7eb;
            color: #111827;
        }

        table {
            width: 100%;
            border-collapse: collapse;
        }

        th,
        td {
            padding: 12px;
            border-bottom: 1px solid #e5e7eb;
            text-align: left;
        }

        label {
            display: block;
            font-weight: 600;
            margin-bottom: 6px;
        }

        input {
            width: 100%;
            padding: 10px 12px;
            border: 1px solid #cbd5e1;
            border-radius: 10px;
            box-sizing: border-box;
        }

        .field + .field {
            margin-top: 16px;
        }

        .error {
            color: #b91c1c;
            font-size: 0.92rem;
            margin-top: 6px;
        }

        .status {
            background: #ecfdf5;
            color: #065f46;
            border-radius: 12px;
            padding: 12px 16px;
        }
    </style>
</head>
<body>
    <div class="container stack">
        @if (session('status'))
            <p class="status">{{ session('status') }}</p>
        @endif

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

resources/views/books/_form.blade.php:

create / edit の両方から @include で使われる共有フォーム。old() で入力値を復元し、@error でフィールドごとにエラーを表示する。

@php
    $book ??= null;
@endphp

<div class="field">
    <label for="title">タイトル</label>
    <input
        id="title"
        name="title"
        type="text"
        value="{{ old('title', $book?->title ?? '') }}"
    >
    @error('title')
        <p class="error">{{ $message }}</p>
    @enderror
</div>

<div class="field">
    <label for="author">著者</label>
    <input
        id="author"
        name="author"
        type="text"
        value="{{ old('author', $book?->author ?? '') }}"
    >
    @error('author')
        <p class="error">{{ $message }}</p>
    @enderror
</div>

<div class="field">
    <label for="price">価格(円)</label>
    <input
        id="price"
        name="price"
        type="number"
        min="0"
        value="{{ old('price', $book?->price ?? '') }}"
    >
    @error('price')
        <p class="error">{{ $message }}</p>
    @enderror
</div>

resources/views/books/index.blade.php:

@extends('layouts.app')

@section('title', '本一覧')

@section('content')
    <section class="card stack">
        <div class="actions">
            <h1 style="margin: 0; margin-right: auto;">本一覧</h1>
            <a class="button" href="{{ route('books.create') }}">本を追加する</a>
        </div>

        <table>
            <thead>
                <tr>
                    <th>ID</th>
                    <th>タイトル</th>
                    <th>著者</th>
                    <th>価格</th>
                    <th>操作</th>
                </tr>
            </thead>
            <tbody>
                @forelse ($books as $book)
                    <tr>
                        <td>{{ $book->id }}</td>
                        <td>{{ $book->title }}</td>
                        <td>{{ $book->author }}</td>
                        <td>{{ number_format($book->price) }} 円</td>
                        <td>
                            <div class="actions">
                                <a class="button subtle" href="{{ route('books.edit', $book) }}">編集</a>
                                <form action="{{ route('books.destroy', $book) }}" method="post">
                                    @csrf
                                    @method('DELETE')
                                    <button type="submit">削除</button>
                                </form>
                            </div>
                        </td>
                    </tr>
                @empty
                    <tr>
                        <td colspan="5">まだ本が登録されていません。</td>
                    </tr>
                @endforelse
            </tbody>
        </table>
    </section>
@endsection

resources/views/books/create.blade.php:

@extends('layouts.app')

@section('title', '本を追加する')

@section('content')
    <section class="card stack">
        <div class="actions">
            <h1 style="margin: 0; margin-right: auto;">本を追加する</h1>
            <a class="button subtle" href="{{ route('books.index') }}">一覧へ戻る</a>
        </div>

        <form action="{{ route('books.store') }}" method="post">
            @csrf
            @include('books._form')

            <div class="actions" style="margin-top: 20px;">
                <button type="submit">保存する</button>
            </div>
        </form>
    </section>
@endsection

resources/views/books/edit.blade.php:

@extends('layouts.app')

@section('title', '本を編集する')

@section('content')
    <section class="card stack">
        <div class="actions">
            <h1 style="margin: 0; margin-right: auto;">本を編集する</h1>
            <a class="button subtle" href="{{ route('books.index') }}">一覧へ戻る</a>
        </div>

        <form action="{{ route('books.update', $book) }}" method="post">
            @csrf
            @method('PATCH')
            @include('books._form', ['book' => $book])

            <div class="actions" style="margin-top: 20px;">
                <button type="submit">更新する</button>
            </div>
        </form>
    </section>
@endsection

old()@error が入っているので、validation 失敗時に create / edit の両方で入力値を戻せます。 _form.blade.php に寄せておくと、フィールド定義の重複も最小限です。

コードのポイント

old()@error でバリデーション往復を処理する

resources/views/books/_form.blade.php のポイントはここ。

<input
    id="title"
    name="title"
    type="text"
    value="{{ old('title', $book?->title ?? '') }}"
>
@error('title')
    <p class="error">{{ $message }}</p>
@enderror

old('title', ...) はバリデーション失敗後に前回の入力値を復元する。第2引数が編集画面でのデフォルト値になる。 @error('title')$errors バッグに該当キーのエラーがある場合だけブロックを描画する。

@forelse / @empty で一覧の空状態を扱う

resources/views/books/index.blade.php のポイントはここ。

@forelse ($books as $book)
    <tr>
        <td>{{ $book->id }}</td>
        <td>{{ $book->title }}</td>
        <td>{{ $book->author }}</td>
        <td>{{ number_format($book->price) }} 円</td>
    </tr>
@empty
    <tr>
        <td colspan="5">まだ本が登録されていません。</td>
    </tr>
@endforelse

@forelse@foreach と空チェックを1つにまとめた Blade 構文で、件数ゼロのとき @empty ブロックに切り替わる。 @foreach と別途 @if($books->isEmpty()) を書く手間が省ける。

@method('DELETE') / @method('PATCH') で HTTP メソッドを偽装する

resources/views/books/index.blade.php の削除フォームのポイントはここ。

<form action="{{ route('books.destroy', $book) }}" method="post">
    @csrf
    @method('DELETE')
    <button type="submit">削除</button>
</form>

HTML フォームは GET / POST しか送れないため、@method('DELETE')_method=DELETE の hidden フィールドを注入する。 Laravel はこれを受け取り、対応するルート books.destroy を実行する。

6. migration を実行して create を確認する

ここまでできたら migration を流します。

Laravel 13 の create-project では database/database.sqlite と初回 migration が用意されるため、前記事どおりに進めているならここで追加の準備は不要です。

docker compose exec app php artisan migrate

次に http://localhost:8000/books/create を開き、次の値で追加してみてください。

  • タイトル: Laravel実践
  • 著者: Sato
  • 価格: 2800
本の追加フォームに入力した状態

保存後に一覧へ戻り、本を追加しました。 のメッセージと追加した 1 行が見えれば成功です。

本一覧に追加した行が表示された状態

そのあと、わざと失敗も確認します。

  • すべてのフィールドを空のままにして「保存する」を押す

この状態で送信すると、同じフォームへ戻り、各フィールドにエラーメッセージが表示されます。 validate() が失敗すると、直前ページへ戻してエラーと入力値をセッションへフラッシュします。著者や価格に何か値を入れた状態で送れば、その値がそのまま残ることも確認できます。

フィールドを空で送信するとバリデーションエラーが表示された状態

database/database.sqlite がない、または migration を忘れていると追加で止まります。
一覧へ戻っても空のままなら、まず php artisan migrate の成否を見直してください。

7. 編集と削除を確認する

一覧の 編集 ボタンから編集画面へ移動し、タイトルか価格を変えて更新します。 更新後に一覧へ戻り、本を更新しました。 が出て内容も変わっていれば OK です。

更新後に一覧へ戻り「本を更新しました。」が表示された状態

続けて 削除 ボタンも押してみてください。 一覧から対象行が消え、本を削除しました。 が出れば削除まで通った状態です。

削除後に一覧が空になり「本を削除しました。」が表示された状態

PATCHDELETE は HTML フォームがそのまま送れないため、Blade 側で @method('PATCH')@method('DELETE') を使っています。
ここが抜けると、update / destroy の route に合わず止まりやすくなります。

最後は route 全体の確認です。

docker compose exec app php artisan route:list

次の route が並んでいれば、CRUD の入口はそろった状態。

  • books.index
  • books.create
  • books.store
  • books.edit
  • books.update
  • books.destroy
migration 実行と route:list の出力

8. よくある詰まりと次の一歩

root 所有で保存できないとき:

  • docker run-u "$(id -u):$(id -g)" を付けたか確認する
  • LOCAL_UID / LOCAL_GID を export してから docker compose up -d を実行したか確認する

View [books.index] not found. が出るとき:

  • resources/views/books/index.blade.php の配置を確認する
  • books.create / books.edit も同じディレクトリにあるか確認する

追加や編集が反映されないとき:

  • docker compose exec app php artisan migrate を実行したか確認する
  • database/database.sqlite が無い場合は touch database/database.sqlite を実行してから、もう一度 migration を流す
  • Book モデルの $fillabletitle / author / price が入っているか確認する

validation エラーで戻る理由が分かりにくいとき:

  • BookControllerstore() / update() にある $request->validate() を見直す
  • books/_form.blade.phpold()@error が効いているか確認する

ここまで進めば、Laravel の最小 CRUD は一度通りました。
続きとして読むなら、まず Laravelで認証を足す(Breeze 最小導入) でログイン必須にし、そのあと Laravelで認可を入れる(Policyで自分の本だけ編集できるようにする) で「自分の本だけ編集できる」状態へ順に進めます。

品質面を先に固めたい場合は、Laravelで品質ゲートを敷く(PHPUnit / Larastan / Pint / GitHub Actions) を挟んでも構いません。

シリーズ 2/16

このシリーズ

Laravelの基本を最初から通す

  1. 1. Laravel入門(Route / Controller / View / Model 最小構成)
  2. 2. Laravelで最小CRUDを作る(一覧 / 作成 / 編集 / 削除) 現在の記事
  3. 3. Laravelで品質ゲートを敷く(PHPUnit / Larastan / Pint / GitHub Actions)
  4. 4. Laravelで認証を足す(Breeze 最小導入)
  5. 5. Laravelで認可を入れる(Policyで自分の本だけ編集できるようにする)
  6. 6. LaravelでQueueを始める(database queue + worker 最小構成)
  7. 7. Laravelでスケジューラを動かす(Command + Scheduler 最小構成)
  8. 8. Laravelでファイルアップロードを扱う(Storage + validation)
  9. 9. Laravelでリレーションを扱う(User / Book / Category の基本)
  10. 10. Laravelで検索・並び替え・ページネーション付き一覧を作る
  11. 11. LaravelでLivewireを始める
  12. 12. LaravelでLivewire一覧画面を作る(検索・並び替え・ページネーション)
  13. 13. LaravelでSanctum認証APIを作る
  14. 14. LaravelでBlade / Livewire / Inertia をどう使い分けるか
  15. 15. LaravelでInertia + Vue.js を始める
  16. 16. LaravelでInertia + React を始める