Laravel入門 は通したものの、一覧 / 作成 / 編集 / 削除を 1 本でつなぐところで止まりやすい読者向けです。
ゴールは、SQLite を使った Book 管理の最小 CRUD を作り、validation と redirect の戻り方まで確認すること。
この 3 本は 最小CRUD -> Breeze認証 -> Policy認可 の順でつながります。
この記事はその起点で、次の Laravelで認証を足す(Breeze 最小導入) と Laravelで認可を入れる(Policyで自分の本だけ編集できるようにする) の前提記事です。
導入前に補助として参照しやすい記事:
- Laravel入門(Route / Controller / View / Model 最小構成)
- Windows 11で始めるPHPローカル開発環境(WSL2 + Docker + PostgreSQL)
前提環境
- Windows 11
- WSL2(Ubuntu)
- VS Code(Remote - WSL)
- Docker Desktop(WSL 連携有効)
以降のコマンドは、特記がない限り WSL 側ターミナルで実行します。
1. ゴールと非対象
この記事で到達する状態:
Book一覧画面を表示できる- 本の追加、編集、削除をブラウザで確認できる
- バリデーションエラー時にフォームへ戻り、入力値とエラー表示が残ることを確認できる
今回は、最小の画面 CRUD を 1 本通すことに絞ります。
認証や Form Request を同時に入れると、Controller と Blade の基本導線が見えにくくなります。
非対象:
- 認証、認可
Form Requestの本格導入FactoryやSeederを使ったデータ準備- 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 です。
続けて 削除 ボタンも押してみてください。
一覧から対象行が消え、本を削除しました。 が出れば削除まで通った状態です。
PATCH と DELETE は HTML フォームがそのまま送れないため、Blade 側で @method('PATCH') と @method('DELETE') を使っています。
ここが抜けると、update / destroy の route に合わず止まりやすくなります。
最後は route 全体の確認です。
docker compose exec app php artisan route:list
次の route が並んでいれば、CRUD の入口はそろった状態。
books.indexbooks.createbooks.storebooks.editbooks.updatebooks.destroy
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モデルの$fillableにtitle/author/priceが入っているか確認する
validation エラーで戻る理由が分かりにくいとき:
BookControllerのstore()/update()にある$request->validate()を見直すbooks/_form.blade.phpのold()と@errorが効いているか確認する
ここまで進めば、Laravel の最小 CRUD は一度通りました。
続きとして読むなら、まず Laravelで認証を足す(Breeze 最小導入) でログイン必須にし、そのあと Laravelで認可を入れる(Policyで自分の本だけ編集できるようにする) で「自分の本だけ編集できる」状態へ順に進めます。
品質面を先に固めたい場合は、Laravelで品質ゲートを敷く(PHPUnit / Larastan / Pint / GitHub Actions) を挟んでも構いません。