Laravel Eloquentリレーション
リレーションはEloquentの「超能力」です—たった1行のコードで, 外部キーを使ってテーブル間の橋を渡し, 手動JOINに別れを告げます。
1. 学ぶ内容
- 1対1:hasOne/belongsTo
- 1対多:hasMany/belongsTo
- 多対多:belongsToMany (ピボットテーブルを含む)
- HasManyThrough:リモートリレーションとポリモーフィックリレーション
- プリローディングとレイジーローディング:N+1問題と
with()/load()による最適化
2. データ分析チームの実話
(1) 悩み:10件の注文を表示するだけで50クエリ
CharlieはShopMetricsダッシュボードで10件の注文を表示します—注文を取得するクエリを1回実行し, その後各注文ごとにユーザー, ストア, 商品のクエリを1回ずつ実行し, 合計1 + 10 x 4 = 41のSQLクエリになります。データベース負荷が急増し, ページ読み込み時間が200msから3秒になりました。BobがさらにJOINを追加し (注文 → 商品 → カテゴリ → タグ), クエリ数は200を超え, Aliceはシステムが「カタツムリ並みに遅い」と不満を言いました。
(2) プリローディングによる解決策
Eloquentのwith()プリロードは, 1回のクエリですべての関連データを取得し, 41のSQL文を4に削減します。
// N+1問題 — 41クエリ
$orders = Order::take(10)->get();
foreach ($orders as $order) {
echo $order->user->name; // 各回+1クエリ
echo $order->shop->name; // 各回+1クエリ
}
// Eagerローディング — 合計4クエリ
$orders = Order::with(['user', 'shop', 'items.product'])->take(10)->get();
foreach ($orders as $order) {
echo $order->user->name; // 追加クエリ0
echo $order->shop->name; // 追加クエリ0
}
(3) 成果
Charlieがプリローディングを実装した後, ダッシュボードのクエリ数は41から4に減少し, ページ読み込み時間は3秒から300ミリ秒に短縮されました。
3. 1対1リレーション
(1) hasOne / belongsTo
// User has one Profile
class User extends Model
{
public function profile(): HasOne
{
return $this->hasOne(Profile::class);
}
}
// Profile belongs to User
class Profile extends Model
{
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
// 使用方法
$profile = $user->profile;
$user = $profile->user;
| 方向 | メソッド | 外部キーの位置 | 説明 |
|---|---|---|---|
| User → Profile | hasOne | profilesテーブル | 持っている |
| Profile → User | belongsTo | profilesテーブル | 属している |
(1) ▶ サンプル:ShopMetricsユーザーとサブスクリプションプラン
// User has one active subscription
class User extends Model
{
public function activeSubscription(): HasOne
{
return $this->hasOne(Subscription::class)
->where('status', 'active')
->latestOfMany();
}
}
// Subscription belongs to User
class Subscription extends Model
{
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
// 使用方法
$plan = $user->activeSubscription->plan;
出力:
// 実行成功
4. 1対多リレーション
(1) hasMany / belongsTo
// Tenant has many Shops
class Tenant extends Model
{
public function shops(): HasMany
{
return $this->hasMany(Shop::class);
}
public function orders(): HasMany
{
return $this->hasMany(Order::class);
}
}
// Shop belongs to Tenant
class Shop extends Model
{
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function products(): HasMany
{
return $this->hasMany(Product::class);
}
public function orders(): HasMany
{
return $this->hasMany(Order::class);
}
}
(2) 7大リレーション型のUML図
classDiagram
class Tenant {
+shops() HasMany
+orders() HasMany
+users() HasMany
+subscription() HasOne
}
class User {
+tenant() BelongsTo
+profile() HasOne
+orders() HasMany
}
class Shop {
+tenant() BelongsTo
+products() HasMany
+orders() HasMany
}
class Product {
+shop() BelongsTo
+categories() BelongsToMany
}
class Category {
+products() BelongsToMany
}
class Order {
+shop() BelongsTo
+user() BelongsTo
+items() HasMany
}
class OrderItem {
+order() BelongsTo
+product() BelongsTo
}
Tenant "1" --> "*" Shop : hasMany
Tenant "1" --> "*" User : hasMany
Tenant "1" --> "1" Subscription : hasOne
Shop "1" --> "*" Product : hasMany
Shop "1" --> "*" Order : hasMany
Product "*" --> "*" Category : belongsToMany
Order "1" --> "*" OrderItem : hasMany
(1) ▶ サンプル:ShopMetricsテナントのストアと注文
// テナントを全ストアとその最近の注文付きで取得
$tenant = Tenant::with(['shops' => function ($query) {
$query->withCount(['orders' => function ($q) {
$q->where('created_at', '>=', now()->subDays(30));
}])->orderBy('revenue', 'desc');
}])->findOrFail($tenantId);
foreach ($tenant->shops as $shop) {
echo "{$shop->name}: {$shop->orders_count} recent orders";
}
出力:
// 実行成功
5. 多対多リレーション
(1) belongsToManyとピボットテーブル
// Product belongs to many Categories (category_productピボット経由)
class Product extends Model
{
public function categories(): BelongsToMany
{
return $this->belongsToMany(Category::class)
->withPivot('is_primary')
->withTimestamps();
}
}
class Category extends Model
{
public function products(): BelongsToMany
{
return $this->belongsToMany(Product::class)
->withPivot('is_primary')
->withTimestamps();
}
}
(2) ピボットテーブル構造
category_product
├── id
├── category_id (FK)
├── product_id (FK)
├── is_primary (BOOLEAN)
├── created_at
└── updated_at
| ピボットメソッド | 目的 |
|---|---|
withPivot() |
ピボット列の追加読み取り |
withTimestamps() |
ピボットのタイムスタンプを管理 |
as('alias') |
ピボットのエイリアス |
wherePivot() |
ピボット条件でフィルタ |
sync() |
関連の同期 (差分セット) |
attach() |
関連の追加 |
detach() |
関連の削除 |
(1) ▶ サンプル:ShopMetrics商品カテゴリ (多対多)
// 商品にカテゴリを紐付け
$product->categories()->attach([1, 2, 3], ['is_primary' => false]);
$product->categories()->attach(4, ['is_primary' => true]);
// sync — 正確なカテゴリを設定 (他は削除)
$product->categories()->sync([
1 => ['is_primary' => false],
4 => ['is_primary' => true],
]);
// syncWithoutDetaching — 追加のみ, 削除しない
$product->categories()->syncWithoutDetaching([5, 6]);
// ピボット条件でクエリ
$primaryCategory = $product->categories()
->wherePivot('is_primary', true)
->first();
// 特定のカテゴリの紐付けを解除
$product->categories()->detach([1, 2]);
出力:
// 実行成功
6. リモートリレーションとポリモーフィックリレーション
(1) HasManyThrough
// Tenant has many Products through Shop
class Tenant extends Model
{
public function products(): HasManyThrough
{
return $this->hasManyThrough(
Product::class, // 最終ターゲット
Shop::class, // 中間
'tenant_id', // shops上のFK
'shop_id', // products上のFK
'id', // tenants上のPK
'id', // shops上のPK
);
}
}
// 使用方法: ストアを読み込まずに直接アクセス
$products = $tenant->products()->where('is_active', true)->get();
(2) ポリモーフィックリレーション
// Image can belong to Shop or Product (morphable)
class Image extends Model
{
public function imageable(): MorphTo
{
return $this->morphTo();
}
}
class Shop extends Model
{
public function images(): MorphMany
{
return $this->morphMany(Image::class, 'imageable');
}
}
class Product extends Model
{
public function images(): MorphMany
{
return $this->morphMany(Image::class, 'imageable');
}
}
// ポリモーフィック用マイグレーション
Schema::create('images', function (Blueprint $table) {
$table->id();
$table->morphs('imageable'); // imageable_type + imageable_id
$table->string('path');
$table->timestamps();
});
| リレーション型 | メソッド | 用途 |
|---|---|---|
| 1対1 | hasOne/belongsTo | User → Profile |
| 1対多 | hasMany/belongsTo | Tenant → Shop |
| 多対多 | belongsToMany | Product ↔ Category |
| リモート1対多 | hasManyThrough | Tenant → Product (Shop経由) |
| ポリモーフィック1対1 | morphOne/morphTo | Image → Product/Shop |
| ポリモーフィック1対多 | morphMany/morphTo | Comment → Product/Article |
| ポリモーフィック多対多 | morphToMany/morphByMany | Tag → Product/Article |
(1) ▶ サンプル:ShopMetricsポリモーフィック画像システム
// ストアに画像を追加
$shop->images()->create(['path' => 'shops/alice-store/banner.jpg']);
// 商品に画像を追加
$product->images()->create(['path' => 'products/widget-a/thumb.jpg']);
// ポリモーフィックでクエリ — 画像の所有者を取得
$image = Image::find(1);
$image->imageable; // ShopまたはProductインスタンスを返す
// ポリモーフィックをEagerロード
$images = Image::with('imageable')->get();
foreach ($images as $image) {
echo $image->imageable->name; // ShopでもProductでも動作
}
出力:
// 実行成功
7. プリローディングとN+1最適化
(1) N+1問題
Eagerローディングなし:
1. SELECT * FROM orders WHERE tenant_id = 1 LIMIT 10 -- 1クエリ
2. SELECT * FROM users WHERE id = 1 -- 注文ごとに+1
3. SELECT * FROM users WHERE id = 2
4. SELECT * FROM shops WHERE id = 5
... (10件の注文で30以上のクエリに)
(2) with()プリローディング
// Eagerローディング — 合計4クエリ
$orders = Order::with(['user', 'shop', 'items.product'])->paginate(15);
// ネストされたEagerローディング
$orders = Order::with(['items.product.categories'])->get();
// 条件付きEagerローディング
$orders = Order::with(['items' => function ($query) {
$query->where('quantity', '>', 1);
}])->get();
// レイジーEagerローディング — 初回クエリ後にロード
$orders = Order::all();
if ($needItems) {
$orders->load('items.product');
}
| メソッド | タイミング | 適用シーン |
|---|---|---|
with() |
クエリ時にロード | 結合が必要と分かっている場合 |
load() |
クエリ後にロード | 条件付きロード |
loadCount() |
カウントのみロード | データ不要, 数量のみ |
loadMissing() |
欠落時にロード | 再ロードの回避 |
(1) ▶ サンプル:ShopMetricsダッシュボードN+1最適化
// 悪い例 — ダッシュボードでN+1問題
$shops = Shop::where('tenant_id', $tenantId)->get();
foreach ($shops as $shop) {
echo $shop->orders->count(); // ストアごとに+1クエリ
echo $shop->products->count(); // ストアごとに+1クエリ
}
// 良い例 — withCount + Eagerローディング
$shops = Shop::where('tenant_id', $tenantId)
->withCount(['orders', 'products', 'orders as recent_orders_count' => function ($q) {
$q->where('created_at', '>=', now()->subDays(30));
}])
->with(['latestOrder'])
->get();
foreach ($shops as $shop) {
echo $shop->orders_count; // 追加クエリ0
echo $shop->recent_orders_count; // 追加クエリ0
echo $shop->latestOrder->total; // 追加クエリ0
}
出力:
// 実行成功
8. 総合例:ShopMetricsテナントデータ集約
// ============================================
// 総合例: ShopMetricsテナントデータ集約
// 対象: 全リレーション型, Eagerローディング, ピボット, ポリモーフィック
// ============================================
// app/Models/Tenant.php
class Tenant extends Model
{
public function users(): HasMany
{
return $this->hasMany(User::class);
}
public function shops(): HasMany
{
return $this->hasMany(Shop::class);
}
public function orders(): HasMany
{
return $this->hasMany(Order::class);
}
public function subscription(): HasOne
{
return $this->hasOne(Subscription::class)->where('status', 'active');
}
public function products(): HasManyThrough
{
return $this->hasManyThrough(Product::class, Shop::class);
}
public function scopeWithStats(Builder $query): Builder
{
return $query->withCount([
'shops as active_shops_count' => fn ($q) => $q->where('status', 'active'),
'orders as monthly_orders_count' => fn ($q) => $q->whereBetween('created_at', [
now()->startOfMonth(), now()->endOfMonth(),
]),
])->withSum('orders as total_revenue', 'total');
}
}
// 使用方法 — 1クエリですべて取得
$tenant = Tenant::withStats()
->with(['subscription.plan', 'shops' => fn ($q) => $q->orderBy('revenue', 'desc')->take(5)])
->findOrFail($tenantId);
$tenant->active_shops_count; // 15
$tenant->monthly_orders_count; // 234
$tenant->total_revenue; // 45678.90
$tenant->subscription->plan->name; // Pro
$tenant->shops->first()->name; // Alice Store
❓ よくある質問
hasOneまたはhasManyを, このテーブルにある場合はbelongsToを, どちらのテーブルにも外部キーがない場合はbelongsToMany (ピボットテーブルが必要)を使用します。1つのモデルが複数の型に属する場合はポリモーフィックリレーションを使用できます。with()とload()はいつ使い分けるべきですか?with()はクエリ時にデータをロードし, 最適なパフォーマンスを提供します。load()はクエリ後にオンデマンドでデータをロードし, 条件付きロード (特定の条件下でのみ関連データが必要な場合など)に適しています。imageable_type列で型を区別するため, 従来の外部キー制約を設定できません。(imageable_type, imageable_id)にインデックスを追加することでパフォーマンスを改善できます。大規模データセットでは, ポリモーフィック結合の代わりに個別テーブルの使用を検討してください。DB::listen()でクエリ数をログに記録したり, laravel/telescopeでモニタリングすることもできます。$shop->productsは動的プロパティ (Collectionを返す)で, $shop->products()はリレーションクエリビルダ (チェーンクエリが可能)です。前者は自動的にクエリを実行し, 後者は手動でget()を呼ぶまでクエリを延期します。📖 まとめ
- 1対1リレーションには
hasOneまたはbelongsToを使用, 外部キーはbelongsTo側にあります - 1対多リレーション (hasMany/belongsTo)は最も一般的なリレーション型です
- 多対多リレーションには
belongsToManyを使用, ピボットテーブルが必要です - HasManyThrough:中間テーブルを通じてリモートリレーションにアクセスします
- ポリモーフィックリレーションにより, 単一モデルが複数の型に属することができます (例:image → product/shop)
- with()によるプリローディングでN+1問題を解決, withCount()はデータをロードせず件数のみカウントします
📝 練習問題
-
基本問題 (⭐):ShopMetricsのTenant→Shop→Productリレーションを定義してください。Tinkerでテストデータを作成し, リレーションにアクセスしてください:
$tenant->shops->first()->products。 -
応用問題 (⭐⭐):
ProductとCategoryの多対多リレーションを実装し,is_primary列を含むピボットマイグレーションを作成し,sync()でカテゴリを同期し, 特定商品のプライマリカテゴリをクエリしてください。 -
チャレンジ (⭐⭐⭐):ポリモーフィックコメントシステムを実装してください (
Commentモデルが商品とストアの両方に関連付け可能)。ダッシュボードですべてのコメントとそのcommentableリレーションをプリロードし, 3つのSQL文のみで済むようにしてください (1:コメント取得 + 1:商品取得 + 1:ストア取得)。



