【Laravel】Eloquentのリレーション:hasMany・belongsToの使い分け

laravelアイキャッチ Laravel

こんにちは、かつコーチです。

前回はEloquentの基本操作を解説しました。

実際のアプリケーションでは、「投稿」と「コメント」、「ユーザー」と「投稿」のように、テーブル同士が関連し合っているケースがほとんどです。

今回は、Eloquentでテーブル同士のつながりを表現する「リレーション」のうち、基本となる hasManybelongsTo の使い分けを解説します。

リレーションとは?

テーブル間の関連をコードで表現する仕組み

リレーションとは、あるモデルが別のモデルとどう関連しているかを、Eloquentのモデルクラスの中に定義する仕組みです。

例えば「1人のユーザーが複数の投稿を持つ」という関係は、データベース的には次のような構造になります。

// postsテーブルには、投稿がどのユーザーのものかを表す user_id カラムがある
Schema::create('posts', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->string('title');
    $table->text('body');
    $table->timestamps();
});

foreignId('user_id')->constrained() は、user_id カラムを users テーブルの id と結びつける外部キー制約(存在しないIDを保存できないようにする仕組み)です。

cascadeOnDelete() を付けておくと、紐づくユーザーが削除されたときに、そのユーザーの投稿も一緒に削除されます。

このテーブル構造を前提に、Eloquentのモデル側でリレーションを定義していきます。

なぜモデルにリレーションを定義するのか

リレーションを定義しなくても、where() を使えば関連データを取得すること自体は可能です。

// リレーションを使わない場合
$posts = Post::where('user_id', $user->id)->get();

ですが、この書き方だとテーブルが増えるたびに似たようなクエリをあちこちに書くことになり、コードの見通しが悪くなります。

リレーションを定義しておけば、$user->posts のようにプロパティ的な感覚でアクセスできるようになり、コードの意図がぐっと読みやすくなります。

hasMany:1対多の「1」側に定義する

基本の書き方

hasMany は「1つのモデルが、複数の別モデルを持つ」関係を表すときに使います。

「1人のユーザーが複数の投稿を持つ」という関係では、User モデル側に hasMany を定義します。

// app/Models/User.php
namespace App\Models;

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

class User extends Model
{
    /**
     * このユーザーが投稿した記事一覧
     */
    public function posts(): HasMany
    {
        return $this->hasMany(Post::class);
    }
}

これで、$user->posts と書くだけで、そのユーザーに紐づく投稿を全件取得できるようになります。

$user = User::findOrFail(1);
$posts = $user->posts; // このユーザーの投稿が全件取得できる

foreach ($posts as $post) {
    echo $post->title;
}

メソッド名の付け方

hasMany を定義するメソッド名は、複数のデータを返すことが分かるように、複数形(posts)にするのが慣習です。

反対に、後述する belongsTo は1件のデータを返すため、単数形(user)にします。

この命名規則を守っておくと、コードを読むだけで「これは複数返るリレーションか、1件だけ返るリレーションか」が一目で分かります。

belongsTo:多対1の「多」側に定義する

基本の書き方

belongsTo は、hasMany とは逆に「あるモデルが、別のモデルに所属している」関係を表すときに使います。

「投稿は1人のユーザーに属している」という関係では、Post モデル側に belongsTo を定義します。

// app/Models/Post.php
namespace App\Models;

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

class Post extends Model
{
    protected $fillable = ['title', 'body', 'user_id'];

    /**
     * この投稿を書いたユーザー
     */
    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}

これで、投稿から投稿者の情報を辿れるようになります。

$post = Post::findOrFail(1);
echo $post->user->name; // この投稿を書いたユーザーの名前

hasManyとbelongsToは必ずセットで考える

hasManybelongsTo は、片方だけを定義しても機能しますが、実務では基本的に両方のモデルに定義しておくのが一般的です。

「ユーザーから投稿一覧を見たい」場面と「投稿から投稿者を見たい」場面の両方が、実際のアプリケーションでは必ずと言っていいほど出てくるからです。

モデルメソッド名リレーション意味
Userposts()hasMany(Post::class)このユーザーの投稿一覧
Postuser()belongsTo(User::class)この投稿の投稿者

「外部キー(user_id)を持っている側が belongsTo」「持たれている側が hasMany」と覚えておくと、迷ったときに判断しやすくなります。

つまずきやすいポイント:N+1問題

ループの中でリレーションを呼び出す落とし穴

私が実際につまずいた経験として一番印象に残っているのが、この「N+1問題」です。

❌ Before:ループの中でリレーションにアクセスする

$posts = Post::all(); // 投稿を10件取得(SQLが1回発行される)

foreach ($posts as $post) {
    echo $post->user->name; // ループのたびにSQLが発行される(10回)
}

このコードは一見普通に動きますし、件数が少ないうちは問題に気づきません。

ですが、私が担当していた管理画面で投稿数が数百件に増えたとき、一覧ページの表示が急に遅くなるという不具合が発生しました。

原因を調べるためにLaravelのデバッグツールでSQLログを確認したところ、posts を取得する1回のSQLに加えて、$post->user にアクセスするたびに SELECT * FROM users WHERE id = ? というSQLが投稿の件数分(つまり数百回)発行されていることが分かりました。

これが「N+1問題」と呼ばれる現象です。

最初の1回のクエリ(posts の取得)に対して、関連データを取得するN回のクエリ(user の取得)が追加で発行されてしまうため、こう呼ばれています。

✅ After:with() で事前にリレーションを読み込む(Eager Loading)

// user情報もまとめて事前に取得しておく(Eager Loading)
$posts = Post::with('user')->get(); // SQLが2回だけ発行される

foreach ($posts as $post) {
    echo $post->user->name; // 追加のSQLは発行されない
}

with('user') を付けるだけで、Laravelは「posts を取得するSQL」と「関連する users をまとめて取得するSQL」の合計2回だけで、同じ結果を得られるようになります。

この1行を追加しただけで、管理画面の表示速度は体感できるレベルで改善しました。

それ以来、私は「ループの中でリレーションにアクセスするコードを書くときは、必ず with() を検討する」ことをルールにしています。

複数のリレーションをまとめて読み込む

with() には配列で複数のリレーション名を渡すこともできます。

// user と comments を両方まとめて事前に読み込む
$posts = Post::with(['user', 'comments'])->get();

関連するリレーションが増えてきたときほど、with() の効果は大きくなります。

一歩先の使い方:件数だけを数える

withCount() でループを避ける

「各投稿に何件コメントがついているか」を一覧表示したいとき、うっかりループの中で count() を呼びたくなりますが、これもN+1問題の原因になります。

// ❌ ループのたびにコメント数を数えるSQLが発行される
$posts = Post::all();
foreach ($posts as $post) {
    echo $post->comments()->count();
}

// ✅ withCount()でまとめて件数を取得する
$posts = Post::withCount('comments')->get();
foreach ($posts as $post) {
    echo $post->comments_count; // 追加のSQLなしで件数にアクセスできる
}

withCount('comments') を使うと、comments_count という属性が自動的に追加され、追加のSQLを発行せずに件数を取得できます。

一覧画面でコメント数やいいね数を表示するような場面では、count() ではなく withCount() を使う習慣をつけておくとよいでしょう。

まとめ

この記事のポイント

  • hasMany は「1対多」の「1」側、belongsTo は「多」側(外部キーを持つ側)に定義する
  • リレーションを定義すると、$user->posts のようにプロパティ感覚で関連データにアクセスできる
  • ループの中でリレーションにアクセスするとN+1問題が発生し、SQLが大量に発行される
  • with() を使ったEager Loadingで、関連データをまとめて事前に取得できる
  • 件数だけが欲しい場合は count() ではなく withCount() を使うと、余計なSQLを避けられる

次に読むべき記事

1対多のリレーションが理解できたところで、次は「多対多」の関係を表現する belongsToMany を解説します。

→ 次の記事:多対多リレーション(belongsToMany)を実装する

タイトルとURLをコピーしました