公開日 2026-04-01

Laravelでリレーションを扱う(User / Book / Category の基本)

Laravel 13 の fresh app で User / Book / Category の hasMany と belongsTo を作り、外部キー migration、一覧表示、N+1 を避ける with() の基本まで確認する。

目次

  1. 前提環境
  2. 1. ゴールと非対象
  3. 2. 新規デモを作って開始位置をそろえる
  4. 3. User -> Book <- Category の形で schema を作る
  5. コードのポイント
  6. 4. model に hasMany / belongsTo を書く
  7. コードのポイント
  8. 5. Seeder と Tinker で relation を確認する
  9. コードのポイント
  10. 6. 一覧画面で関連データを読み出す
  11. コードのポイント
  12. 7. N+1 の入口を押さえる
  13. 8. よくある詰まりどころ
  14. HasMany / BelongsTo の import を忘れる
  15. php artisan db:seed だけを繰り返して重複データになる
  16. .env の SQLite パスがずれている
  17. with() を外したまま一覧画面を育てる
  18. 9. まとめ

Laravel 13 の CRUD で次に詰まりやすいのが、model 同士の関係をどこから組み立てるかです。この記事では fresh app を起点に User / Book / Category の 3 model を用意し、hasMany / belongsTo、外部キー migration、Seeder、一覧表示、with() による eager loading までを 1 本でつなぎます。belongsToMany、認証、認可、検索、並び替え、ページネーションは扱いません。

前提環境

  • Windows 11
  • WSL2(Ubuntu)
  • VS Code(Remote - WSL)
  • Docker Desktop(WSL 連携有効)
  • composer:2 コンテナ
  • Laravel 13
  • SQLite

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

1. ゴールと非対象

この記事で到達する状態は次の 5 つです。

  • books テーブルに user_idcategory_id の外部キーを持たせる
  • User::books()Category::books()hasMany を書く
  • Book::user()Book::category()belongsTo を書く
  • Seeder で relation 経由の createMany() を使い、サンプルデータを流す
  • 一覧画面で所有者とカテゴリを表示し、with(['user', 'category']) が必要な理由を説明できる

今回は relation の最初の 1 本に絞ります。次の内容は別記事に回します。

  • belongsToMany や pivot table
  • Breeze 認証や Policy
  • 検索、並び替え、ページネーション
  • relation のテスト

2. 新規デモを作って開始位置をそろえる

Laravel 13 の installation docs では laravel new が基本導線です。この記事ではローカルに PHP を直接入れずに試すため、composer:2 コンテナで fresh app を作ります。

wsl

開始位置は ~/projects/laravel-relations-demo です。create-project laravel/laravel . は空のディレクトリで実行してください。以前の試行でファイルが残っていると Project directory "/app/." is not empty. で止まります。

mkdir -p ~/projects/laravel-relations-demo
cd ~/projects/laravel-relations-demo
code .
docker run --rm -u "$(id -u):$(id -g)" -v "$(pwd):/app" -w /app composer:2 create-project laravel/laravel .

compose.yml は次の内容で作成します。

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

user をホスト側の UID / GID にそろえつつ、php artisan tinker 内の PsySH が /.config/psysh へ書こうとして notice を出さないよう、HOMEXDG_CONFIG_HOME/tmp に寄せています。

SQLite を使うので、.env の DB 周りをそろえます。Laravel 13 の fresh app では database/database.sqlite が既にあることがあります。手元に無い場合だけ touch database/database.sqlite で作成してください。

.env の DB 周りは次のように更新してください。

APP_URL=http://localhost:8000
DB_CONNECTION=sqlite
DB_DATABASE=/app/database/database.sqlite

コンテナを起動します。

export LOCAL_UID="$(id -u)"
export LOCAL_GID="$(id -g)"
docker compose up -d
docker compose ps

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

3. User -> Book <- Category の形で schema を作る

今回の relation は、1 人のユーザーが複数の本を持ち、1 つのカテゴリにも複数の本がぶら下がる形です。BookUserCategory の両方に属する立場です。

erDiagram
    User ||--o{ Book : hasMany
    Category ||--o{ Book : hasMany

Laravel fresh app には users テーブルと User model が最初からあります。ここで追加するのは CategoryBook の 2 つだけ。

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

database/migrations/*_create_categories_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('categories', function (Blueprint $table) {
			$table->id();
			$table->string('name')->unique();
			$table->timestamps();
		});
	}

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

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->foreignId('user_id')->constrained()->cascadeOnDelete();
			$table->foreignId('category_id')->constrained()->cascadeOnDelete();
			$table->timestamps();
		});
	}

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

最後に migration を実行します。

docker compose exec app php artisan migrate

コードのポイント

① 外部キーは子テーブルの books 側に置く

$table->string('title');
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('category_id')->constrained()->cascadeOnDelete();
$table->timestamps();

BookUserCategory に属するため、外部キーは books テーブルに入ります。親側の userscategoriesbook_id を置く形ではありません。

constrained() で参照先を Laravel の規則どおりに推論できる

$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('category_id')->constrained()->cascadeOnDelete();

user_idusers.idcategory_idcategories.id を既定の命名規則から推論します。最初の relation では、この規則に合わせて column 名を付けるのが分かりやすい形です。

4. model に hasMany / belongsTo を書く

次は Eloquent 側に relation を定義します。親側の UserCategoryhasMany、子側の BookbelongsTo を持たせる構成です。

既存の app/Models/User.php では、use と method の追加位置が分かるように次の形で追記します。

use Illuminate\Database\Eloquent\Relations\HasMany;

class User extends Authenticatable
{
	// 既存のプロパティとメソッド

	public function books(): HasMany
	{
		return $this->hasMany(Book::class);
	}
}

app/Models/Category.php は次の内容に更新します。

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Category extends Model
{
	protected $fillable = [
		'name',
	];

	public function books(): HasMany
	{
		return $this->hasMany(Book::class);
	}
}

app/Models/Book.php は次の内容に更新します。

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Book extends Model
{
	protected $fillable = [
		'title',
		'user_id',
		'category_id',
	];

	public function user(): BelongsTo
	{
		return $this->belongsTo(User::class);
	}

	public function category(): BelongsTo
	{
		return $this->belongsTo(Category::class);
	}
}

コードのポイント

① 親側は hasMany、子側は belongsTo で対応させる

class Category extends Model
{
	protected $fillable = [
		'name',
	];

	public function books(): HasMany
	{
		return $this->hasMany(Book::class);
	}
}

Category は複数の Book を持つので hasMany です。同じ向きで User::books() も定義します。

② 子側の Book からは親をたどる

class Book extends Model
{
	protected $fillable = [
		'title',
		'user_id',
		'category_id',
	];

	public function user(): BelongsTo
	{
		return $this->belongsTo(User::class);
	}

	public function category(): BelongsTo
	{
		return $this->belongsTo(Category::class);
	}
}

一覧画面で所有者名やカテゴリ名を出す場面では、Book から親へたどる形のほうが扱いやすくなります。そのため Book 側に belongsTo を 2 本持たせます。

5. Seeder と Tinker で relation を確認する

画面を作る前に、relation が本当に動くかを model 単位で確かめます。ここでは DatabaseSeeder を使います。

database/seeders/DatabaseSeeder.php は次の内容に更新します。

<?php

namespace Database\Seeders;

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

class DatabaseSeeder extends Seeder
{
	public function run(): void
	{
		$alice = User::factory()->create([
			'name' => 'Alice',
			'email' => 'alice@example.com',
		]);

		$bob = User::factory()->create([
			'name' => 'Bob',
			'email' => 'bob@example.com',
		]);

		$laravel = Category::query()->create([
			'name' => 'Laravel',
		]);

		$database = Category::query()->create([
			'name' => 'Database',
		]);

		$testing = Category::query()->create([
			'name' => 'Testing',
		]);

		$alice->books()->createMany([
			[
				'title' => 'Laravel入門ノート',
				'category_id' => $laravel->id,
			],
			[
				'title' => 'Eloquent整理メモ',
				'category_id' => $database->id,
			],
		]);

		$bob->books()->createMany([
			[
				'title' => 'PHPUnit最初の一歩',
				'category_id' => $testing->id,
			],
			[
				'title' => 'クエリ改善メモ',
				'category_id' => $database->id,
			],
		]);
	}
}

データを入れ直します。

docker compose exec app php artisan migrate:fresh --seed

次に Tinker で relation を確認します。2章の compose.yml では HOMEXDG_CONFIG_HOME/tmp に寄せてあるので、PsySH の設定保存先で notice が出にくい状態です。

docker compose exec app php artisan tinker
use App\Models\Book;
use App\Models\Category;
use App\Models\User;

User::query()
	->with('books')
	->where('email', 'alice@example.com')
	->first()
	->books
	->pluck('title');

$book = Book::query()->with(['user', 'category'])->first();
[$book->title, $book->user->name, $book->category->name];

Category::query()
	->with('books')
	->where('name', 'Database')
	->first()
	->books
	->pluck('title');

UserCategory から books が取れ、Book から usercategory をたどれれば relation はつながっています。たとえば最後の pluck('title')Database カテゴリ配下の 2 冊が返れば、Category::books() まで確認できます。

TinkerでDatabaseカテゴリ配下の本タイトルを確認した画面

コードのポイント

createMany() を relation 経由で呼ぶと user_id を自動で埋められる

$alice->books()->createMany([
	[
		'title' => 'Laravel入門ノート',
		'category_id' => $laravel->id,
	],
	[
		'title' => 'Eloquent整理メモ',
		'category_id' => $database->id,
	],
]);

User::books()hasMany でつながっているため、user_id を配列に毎回書かなくても relation 側が補います。Seeder でも relation の向きがそのまま使える形です。

② Tinker で先に確認すると view 側の原因切り分けが楽になる

$book = Book::query()->with(['user', 'category'])->first();
[$book->title, $book->user->name, $book->category->name];

一覧画面で何も出ないとき、schema と relation のどちらで止まっているかを画面だけで追うのは面倒です。先に Tinker で relation を確かめておくと、後続は controller と view の問題に絞れます。

6. 一覧画面で関連データを読み出す

relation が動いたら、Book の一覧でタイトル、カテゴリ、所有者を表示します。ここで with(['user', 'category']) を入れて eager loading まで先に済ませます。

docker compose exec app php artisan make:controller BookController

app/Http/Controllers/BookController.php は次の内容に更新します。

<?php

namespace App\Http\Controllers;

use App\Models\Book;
use Illuminate\View\View;

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

routes/web.php は次の内容に更新します。

<?php

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

Route::redirect('/', '/books');

Route::get('/books', [BookController::class, 'index'])
	->name('books.index');

ルートを保存したら、/books が登録されていることを確認します。

docker compose exec app php artisan route:list --path=books

resources/views/books/index.blade.php は次の内容で作成します。Node.js や Vite build を前提にしないため、ここでは Tailwind CDN を使います。

<!DOCTYPE html>
<html lang="ja">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>Books</title>
        <script src="https://cdn.tailwindcss.com"></script>
    </head>
    <body class="bg-slate-100 text-slate-900">
        <main class="mx-auto flex min-h-screen max-w-5xl flex-col gap-6 px-4 py-10">
            <header class="space-y-2">
                <p class="text-sm font-semibold uppercase tracking-[0.2em] text-sky-700">Laravel Relations Demo</p>
                <h1 class="text-3xl font-bold">Book 一覧で relation を確認する</h1>
                <p class="text-sm text-slate-600">Book から User Category をたどって、タイトル・カテゴリ・所有者を同じ画面に表示します。</p>
            </header>

            <section class="overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-slate-200">
                <table class="min-w-full divide-y divide-slate-200 text-sm">
                    <thead class="bg-slate-50 text-left text-slate-600">
                        <tr>
                            <th class="px-4 py-3 font-medium">Title</th>
                            <th class="px-4 py-3 font-medium">Category</th>
                            <th class="px-4 py-3 font-medium">Owner</th>
                        </tr>
                    </thead>
                    <tbody class="divide-y divide-slate-100 bg-white">
                        @foreach ($books as $book)
                            <tr>
                                <td class="px-4 py-3 font-medium text-slate-900">{{ $book->title }}</td>
                                <td class="px-4 py-3">
                                    <span class="inline-flex rounded-full bg-sky-100 px-3 py-1 text-xs font-semibold text-sky-800">
                                        {{ $book->category->name }}
                                    </span>
                                </td>
                                <td class="px-4 py-3 text-slate-700">{{ $book->user->name }}</td>
                            </tr>
                        @endforeach
                    </tbody>
                </table>
            </section>
        </main>
    </body>
</html>

ブラウザで http://localhost:8000/books を開き、サンプルの 4 行が見えれば OK です。

Book一覧でカテゴリと所有者を表示した画面

コードのポイント

① 一覧表示では最初から eager loading を入れる

return view('books.index', [
	'books' => Book::query()
		->with(['user', 'category'])
		->latest()
		->get(),
]);

view で $book->user->name$book->category->name を参照するなら、controller 側で relation をまとめて読んでおくほうが安全です。最初の一覧から with() を入れておくと、後で件数が増えたときも挙動を説明しやすくなります。

② view 側は relation をたどるだけに絞る

@foreach ($books as $book)
	<tr>
		<td class="px-4 py-3 font-medium text-slate-900">{{ $book->title }}</td>
		<td class="px-4 py-3">
			<span class="inline-flex rounded-full bg-sky-100 px-3 py-1 text-xs font-semibold text-sky-800">
				{{ $book->category->name }}
			</span>
		</td>
		<td class="px-4 py-3 text-slate-700">{{ $book->user->name }}</td>
	</tr>
@endforeach

Blade では relation 名をたどって値を出すだけです。SQL や join を view に持ち込まないことで、relation の責務が model と controller に残ります。

7. N+1 の入口を押さえる

with() を入れる理由は、一覧画面で relation を何度も参照するからです。もし controller を次のように変えると、最初に books を 1 回読んだあと、各行で usercategory をたどるたびに追加クエリが発生しやすくなります。

Book::query()
	->latest()
	->get();

今回の一覧は 4 行なので違いが見えにくいかもしれません。ただ、行数が増えるほど「1 回の一覧表示なのに relation ごとの追加クエリが増える」形になります。N+1 の入口です。

一方、今回使った形なら books、関連する users、関連する categories を先にまとめて読みます。

Book::query()
	->with(['user', 'category'])
	->latest()
	->get();

relation を deep にたどる話や withCount() は別テーマです。まずは「一覧で relation を出すときは with() を先に考える」で十分です。

8. よくある詰まりどころ

HasMany / BelongsTo の import を忘れる

method だけ書いて import を忘れると型が解決できません。User.phpCategory.php では HasManyBook.php では BelongsTo の import を確認してください。

php artisan db:seed だけを繰り返して重複データになる

今回の Seeder は create() を使っているため、同じカテゴリ名を何度も足すと一意制約に引っかかります。検証中は migrate:fresh --seed で毎回入れ直すほうが安全です。

.env の SQLite パスがずれている

コンテナ内で artisan を動かすので、DB_DATABASE/app/database/database.sqlite のようにコンテナ内パスでそろえます。WSL 側の絶対パスをそのまま書くと読めません。

with() を外したまま一覧画面を育てる

小さいサンプルでは動いて見えますが、一覧件数が増えると relation 参照のたびにクエリが増えます。Book の一覧で親データを使うときは、controller で eager loading を先に考える癖を付けておくと後で楽です。

9. まとめ

Laravel の relation 入門では、外部キーは子テーブルへ持たせ、親側に hasMany・子側に belongsTo を置く形が出発点です。今回は User -> Book <- Category の 3 model で、migration、Seeder、Tinker、一覧画面、with() の入口までを通しました。

最小 CRUD から relation へ進みたい場合は、Laravelで最小CRUDを作る(一覧 / 作成 / 編集 / 削除) も合わせて読むと流れがつながります。次は、この一覧に検索・並び替え・ページネーションを足すと、日常的な Web アプリの形に近づきます。

シリーズ 9/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 を始める