every Tech Blog

株式会社エブリーのTech Blogです。

Laravel開発で注意したい Eloquentの落とし穴と正しい使い方

はじめに

こんにちは、リテールハブ開発部でバックエンドエンジニアをしているホシと言います。
現在、小売アプリの開発でLaravel11を利用してAPI開発を行っています。

今回はとても便利で、開発効率を大きく上げてくれるツール「LaravelのEloquent ORM」についてお話できればと思います。
ただ、Eloquentに限った話ではなくORM全体の話でもあるのですが、使い方を間違えるとパフォーマンス低下や予期しないバグを引き起こすこともあります。
実際に使用してみて、便利さの裏にある注意点や、SQLの知識・理解が非常に重要であることを実感したので、今回はその点についてお話しできればと思います。

1. Eloquentとは? Laravelでのデータ操作をシンプルにするORM

Eloquentは、Laravelに標準搭載されているORM(Object-Relational Mapping)です。
これを利用することで、データベースのテーブルをPHPのオブジェクトとして扱えるようになります。

通常、データベース操作を行うにはSQLを書く必要がありますが、Eloquentを使えばSQLを使用せず、PHPコードだけでデータの取得・更新・削除などができます。

たとえば、usersテーブルのID=1のnameデータを取得する場合、SQLの場合は以下のように書きます。

SELECT name FROM users WHERE id = 1;

ただ、上記はデータベースから取得するためのSQLというだけで、 実際のコードでは、このSQLを実行した上で、結果からnameを取り出す処理を別途記述する必要があります。

Eloquentを使用すれば、上記の処理が含まれた状態でデータ取得できます。 その代わり、Eloquentはモデルの定義が事前に必要です。

このコードでID=1のユーザのnameを取得できます。

<?php

$users = User::find(1);
$users->name; // ユーザ名の取得

Userはusersテーブルに対応する「モデル」クラスです。 モデルを通じて、EloquentはSQLを自動で生成・実行してくれます。 このモデルを使用することで、取得したデータも簡単にアクセスできます。

モデルとテーブルの関係

Laravelでは、モデル名とテーブル名は命名規則に従って自動的に対応してくれます。 あらかじめUserモデルを定義しておくことで、上記のようなEloquentを使用したデータ取得ができます。

モデル名:User
対応テーブル名:users

また、Eloquentの強みの1つが「リレーション(テーブル間の関連)」を簡単に扱える点です。 モデル間のリレーションも簡単に定義することができます。

例えば、1人のユーザが複数の投稿(Post)を持つ「1対多」の関係を定義する場合:

<?php

// Userモデル
public function posts() {
    return $this->hasMany(Post::class);
}

と定義することで、ユーザの投稿一覧も簡単に取得できるようになります。

以下のコードでID=1のユーザの投稿内容を取得できます。

<?php

$user = User::find(1);
$posts = $user->posts;

SQLのようにJOINを意識せず、オブジェクトの形で関連データを取得できます。

このように、データの取得からアクセスまでを一貫して扱える点が、Eloquentの大きな利点です。

2. Eloquentの「落とし穴」:気付きにくいN+1問題とその回避法

前述の通り非常に簡単にデータ取得ができてしまい、様々なケースでも何となくの理解で私自身使用していました。 ただ、実際は裏でどのように動いているのかを理解して使用しないと思わぬ落とし穴があることがわかりました。
そこで、Eloquentの動きを理解せず使用していると特に遭遇しやすいN+1問題についてお話します。

N+1問題とは?

例えば、ユーザ一覧とそれぞれの投稿数を表示したいとします。

usersテーブルとpostsテーブルは1対多の関係で定義しているとします。

ユーザ毎の投稿数を表示するコード:

<?php

$users = User::all();
foreach ($users as $user) {
    echo $user->posts->count();
}

一見シンプルで正しく動作しているように見えますが、実際にはパフォーマンスが大幅に低下する書き方です。

データ取得の流れとして、

  • 最初にusersテーブルを1回検索
  • その後、各userに対して postsテーブルへクエリ(投稿数N回)を繰り返し検索

つまり、合計で1 + N回のクエリが実行されてしまいます。
例えばユーザが1000人いれば、クエリは合計で1001回実行されることになり、パフォーマンスに大きな影響を与えます。
開発時はデータ数も限られているため、特に遅くなるわけでもエラーになるわけでもなく正常に取得できるため、動きを理解していないと問題に気付きにくいです。

ORMにおける「Eager」と「Lazy」の考え方

Eloquent(ORM)でリレーションを扱うときに登場する重要な概念が、「Eager Loading(イーガーローディング)」「Lazy Loading(レイジーローディング)」があります。

どちらもリレーション先のデータを取得する方法ですが、パフォーマンスに大きな影響を与えるため、使い分けが非常に重要です。

Lazy Loading(遅延読み込み)

デフォルトでは、Eloquentはリレーションを「必要になったとき」に読み込みます。
これがLazy(レイジー)=遅延読み込みです。

この方法の大きなメリットは上述の通り必要な時に読み込むということになります。
例えばメインデータを取得時点では不要なケースで、ある操作や表示時などの必要な時だけに読み込むようにすれば、 余計な読み込みが減り、リソースを効率的に使用できます。

しかし、その代わり繰り返し取得しないといけないケースなどではパフォーマンスに大きな懸念があります。

Eager Loading(即時読み込み)

上記を回避するために使うのが、Eager Loadingです。 リレーションを最初からまとめて読み込む方法です。

コード例:
「with」を使用して関係テーブルを記述します。

<?php

$users = User::with('posts')->get();

foreach ($users as $user) {
    echo $user->posts->count(); // 追加クエリなしでアクセスできる
}

この場合、発行されるSQLは以下の2回のみになります。

SELECT * FROM users;

SELECT * FROM posts WHERE user_id IN (...);

→これがN+1問題の回避につながります。

ただし、Eager Loadingにも注意すべき点があります。
上記はwith指定があるため、Eager Loadingとして、

SELECT * FROM posts WHERE user_id IN (...);

を一緒に実行して、1回の検索で必要なposts情報が取得できています。
しかし、このIN句に入るIDがどの程度の規模かをしっかり把握しておかないと予期せぬ大量データの事前取得につながります。
ここがSQLのJOINを使用した考え方と大きく異なるところかと思います。 もちろんJOINでも大量データの考慮は必要ですが、JOINの場合は1クエリで取得でき、より大量データ取得とのパフォーマンスは高くなります。
こちらの件はうまく使い分けることの重要性の話でもあり、後ほど説明できればと思います。

以下のSQLのように、

select * from users inner join posts on users.id = posts.user_id

この結合であれば、仮に1万のusersデータがあっても問題なく取得はできますが、 Eager Loadingでは、user_id IN (...) のように複数IDを一括で取得するため、IN句に大量のIDが含まれる可能性があり、DBの制約、メモリ使用量や処理時間に影響を及ぼします。 この事象が起きることは常に考慮してEloquentを使用する必要があります。

N+1を避けるには?

リレーションをループで使うときは必ずwith()の使用を検討します。
また、例えばusersにposts、postsにcommentsが関連している場合、正しくwithを指定します。

<?php

User::with(['posts', 'posts.comments'])->get();

とそれぞれ指定する必要があります。
これも理解していれば特に問題はないのですが、私自身「'posts.comments'」だけでどちらのテーブルも入っているので十分だと思っていた時期がありました。
普通に動くので問題があることがしばらく気付けず・・・。

また、当たり前ですがプログラム上で繰り返し取得するようなケースを書いている場合もN+1問題と同様の事象になるので、 そういったコードについても対策は必ず行う必要があります。

3. Eloquentを使用する場合もSQL知識、理解は非常に重要

データを取得する際、シンプルな取得であれば前述したような1行、2行で記載でき、特にSQLを意識する必要はあまりありませんが、 少し複雑な条件であったり、複数のテーブルをまとめて取得するようなケースでは、Eloquentのコードだけでは、どのようなSQLが生成・実行されているかを把握しづらいことがあります。

少ないデータやシンプルなテーブル構成というのは実際のサービスではほとんどないと思いますので、 コードを実装する上では結局どのようなSQLを使用してデータを取得しているのかをしっかりと把握する必要があります。

特に重要なSQL理解

  • INNER JOIN、LEFT or RIGHT JOINなどの結合仕様
  • リレーション先のテーブルに対してのWHERE条件のための結合やサブクエリの知識
  • 今後のデータ数に合わせたパフォーマンス考慮、インデックス設定の検討
実行SQLを確認する方法(何となく取得できていそうを避ける)

Eloquentを使っていると、裏でどんなSQLが発行されているか分かりづらいことがあるため、 開発時は常にどのようなSQLが発行されているかを以下のログ出力を利用して確認できるようにしておきます。

私の失敗例:

<?php

$query = User::where('email', 'like', '%@example.com');
dd($query->toSql());

上記は以下のSQLが返ってきます。

select * from `users` where `email` like ?

しかし、実際の値が分からないことに加え、Eloquentでは単なるSQLの実行とは異なり、 モデル経由で値を取得する際に、Lazy Loadingによって上記以外の場所で意図せず追加のSQLが実行されているケースもあります。

ここで私は他にSQL発行していることに気づけず、どのタイミングでどのようにpostsのデータが取得できているのかをしばらく調べることになりました・・・。

正しい確認方法:DB::listen()を使用する
<?php

use Illuminate\Support\Facades\DB;

DB::listen(function ($query) {
    logger()->info('SQL実行ログ', [
        'sql' => $query->sql,
        'bindings' => $query->bindings,
        'time' => $query->time . ' ms',
    ]);
});

※このログ出力は開発環境のみに限定するなど制御することで、不要なログ蓄積やセキュリティリスクを防げます。

このコードをLaravelにあるAppServiceProviderのboot()メソッドに仕込むことで、アプリ全体のクエリをログで確認できます。 これにより、どのようなSQLが実行されているかをすべてログで確認することができるようになります。

なぜこのログ出力が大事か

ログ設定を行った場合に実際に出力されるログ内容です。

[2025-05-XX 12:34:56] local.INFO: SQL実行ログ {
  sql: "select * from `users` where `email` like ?",
  bindings: ["%@example.com"],
  time: "1.22 ms"
}

このログ出力は、開発において非常に重要な情報源となります。

  • 想定しているクエリが発行されているか確認できる。
  • パフォーマンスのボトルネックを見つけやすくなる。(実行時間、実行回数など)
  • 中間処理やAPIレスポンスの生成時など、意図していなかった箇所でクエリが実行されていることがわかるようになる。

他にツールの設定などでN+1問題を見つける方法などもありますので、そういったものも併用するとより解消しやすいかと思います。 ただ、上記を行うだけでも格段にSQLの問題は見つけやすくなります。

これまでの内容を踏まえ、Laravelのデータ操作の最適な方法は?

Laravelには2つの主要なデータベース操作手段があります:

  • Eloquent:LaravelのORM。モデルベースで直感的・オブジェクト指向。
  • Query Builder:SQL構文ベース。柔軟で高速。

両者には得意不得意があり、場面によって使い分けることが大切です。

以下のようなケースでは無理にEloquentにこだわらず、状況によってはQuery Builderの使用も検討すべきです。

  • 集計・統計クエリ(GROUP BY / COUNT / JOIN)を使用したデータ取得
  • 大量データの取得、更新処理など
  • サブクエリ・複雑なWHERE句を使用したデータ取得
  • モデルが不要な一時的なテーブル操作やJOINを使用したデータ取得

それぞれの補完関係を表にしてみました。

EloquentとQuery Builderの使い分け比較

比較項目 Eloquent Query Builder
可読性 ◯(SQLに近い)
モデルの活用
複雑なクエリ構造
パフォーマンス △(特に大量処理)
柔軟な構文制御
チーム開発との親和性 ◎(モデルベースで役割明確) ◯(要コメントや命名工夫)

まとめ:Eloquentを安全に、賢く使うために

いかがでしたでしょうか。
Eloquentは非常に便利ですが、SQL知識、理解があってこそ真価を発揮するのではないかと思います。
それぞれの特性を活かして、最適なデータ取得ができるようにしていきたいです。
また、実際に実行されるSQLを常に意識し、必要に応じてログを確認する習慣を持つことが重要だと思います。
特にAPI作成の上でパフォーマンスを意識するなら、SQLの知識も必須かと思います。
Eloquentの便利さを活かしながらも、裏側の仕組みや発行されるSQLを意識し、より安定した高パフォーマンスな開発ができるようにしていきたいです。

もしSQLをログで確認していない方は、ぜひログ設定をして実行されるSQLを確認していきましょう!

今回の記事が少しでも皆さんの開発のヒントになれば幸いです。
最後までお読みいただきありがとうございました。