404 Not Found

404 Not Found


nginx

Laravel Eloquentリレーション

リレーションはEloquentの「超能力」です—たった1行のコードで, 外部キーを使ってテーブル間の橋を渡し, 手動JOINに別れを告げます。

1. 学ぶ内容


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に削減します。

PHP
// 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

PHP
// 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ユーザーとサブスクリプションプラン

PHP
// 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;

出力:

TEXT
// 実行成功

4. 1対多リレーション

(1) hasMany / belongsTo

PHP
// 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図

100%
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テナントのストアと注文

PHP
// テナントを全ストアとその最近の注文付きで取得
$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";
}

出力:

TEXT
// 実行成功

5. 多対多リレーション

(1) belongsToManyとピボットテーブル

PHP
// 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) ピボットテーブル構造

TEXT
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商品カテゴリ (多対多)

PHP
// 商品にカテゴリを紐付け
$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]);

出力:

TEXT
// 実行成功

6. リモートリレーションとポリモーフィックリレーション

(1) HasManyThrough

PHP
// 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) ポリモーフィックリレーション

PHP
// 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ポリモーフィック画像システム

PHP
// ストアに画像を追加
$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でも動作
}

出力:

TEXT
// 実行成功

7. プリローディングとN+1最適化

(1) N+1問題

TEXT
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()プリローディング

PHP
// 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最適化

PHP
// 悪い例 — ダッシュボードで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
}

出力:

TEXT
// 実行成功

8. 総合例:ShopMetricsテナントデータ集約

PHP
// ============================================
// 総合例: 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

❓ よくある質問

Q どのリレーションを使うべきかどう判断しますか?
A 外部キーの位置を見てください—外部キーが相手のテーブルにある場合はhasOneまたはhasManyを, このテーブルにある場合はbelongsToを, どちらのテーブルにも外部キーがない場合はbelongsToMany (ピボットテーブルが必要)を使用します。1つのモデルが複数の型に属する場合はポリモーフィックリレーションを使用できます。
Q with()load()はいつ使い分けるべきですか?
A with()はクエリ時にデータをロードし, 最適なパフォーマンスを提供します。load()はクエリ後にオンデマンドでデータをロードし, 条件付きロード (特定の条件下でのみ関連データが必要な場合など)に適しています。
Q 「sync」と「attach」の違いは何ですか?
A 「attach」は単に関連を追加する (古いものは削除しない)のに対し, 「sync」は正確な関連リストを設定します (過剰なエントリは「detach」されます)。「完全置換」には「sync」を, 「段階的追加」には「attach」を使用してください。
Q ポリモーフィック結合はクエリパフォーマンスに影響しますか?
A ポリモーフィック結合はimageable_type列で型を区別するため, 従来の外部キー制約を設定できません。(imageable_type, imageable_id)にインデックスを追加することでパフォーマンスを改善できます。大規模データセットでは, ポリモーフィック結合の代わりに個別テーブルの使用を検討してください。
Q N+1問題を検出するにはどうすればよいですか?
A Laravel Debugbarをインストールし, 各ページのSQLクエリ数を確認してください。20を超えている場合は通常, N+1問題を示しています。DB::listen()でクエリ数をログに記録したり, laravel/telescopeでモニタリングすることもできます。
Q リレーションメソッドにカッコありとなしの違いは何ですか?
A $shop->productsは動的プロパティ (Collectionを返す)で, $shop->products()はリレーションクエリビルダ (チェーンクエリが可能)です。前者は自動的にクエリを実行し, 後者は手動でget()を呼ぶまでクエリを延期します。

📖 まとめ


📝 練習問題

  1. 基本問題 (⭐):ShopMetricsのTenant→Shop→Productリレーションを定義してください。Tinkerでテストデータを作成し, リレーションにアクセスしてください:$tenant->shops->first()->products

  2. 応用問題 (⭐⭐):ProductCategoryの多対多リレーションを実装し, is_primary列を含むピボットマイグレーションを作成し, sync()でカテゴリを同期し, 特定商品のプライマリカテゴリをクエリしてください。

  3. チャレンジ (⭐⭐⭐):ポリモーフィックコメントシステムを実装してください (Commentモデルが商品とストアの両方に関連付け可能)。ダッシュボードですべてのコメントとそのcommentableリレーションをプリロードし, 3つのSQL文のみで済むようにしてください (1:コメント取得 + 1:商品取得 + 1:ストア取得)。

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%