LaravelのN+1問題を完全解決!with・loadによるEager Loadingと検知・防止テクニック

実装・応用テクニック

Laravel(Eloquent)を使ったアプリケーション開発で、パフォーマンス低下の主要な原因として最も頻繁に遭遇するのが「N+1問題」です。

開発環境の少量のテストデータでは軽快に動いていても、本番環境でデータ量が増えた途端にレスポンスが極端に遅くなったり、データベースサーバーのCPU負荷が跳ね上がったりするトラブルの大半は、このN+1問題に起因しています。

この記事では、N+1問題が発生する根本原因と発行されるSQLの挙動、with()load() を用いたイーガーローディング(Eager Loading)による解決手順、Model::preventLazyLoading() を使った開発環境での検知・自動防止テクニックまで、実務ですぐに役立つ実践コード付きで徹底解説します。


  1. 【早見表】N+1問題の解決パターン
  2. N+1問題とは?なぜ発生するのか(仕組みとSQL挙動)
    1. N+1問題が発生する具体例コード
    2. 実際にデータベースへ発行されるSQL
  3. 解決策1:with() によるEager Loading(事前一括取得)
    1. 基本構文
    2. with() で発行されるSQL
    3. 複数のリレーションを同時にイーガーローディングする
    4. ネストしたリレーションを取得する(ドット記法)
    5. 取得カラムを限定してメモリを節約する
    6. 条件付きイーガーローディング(制約の追加)
  4. 解決策2:load() / loadMissing() によるLazy Eager Loading(遅延一括取得)
    1. load() の使い方
    2. loadMissing() で不要な再取得を防止する
  5. 件数・集計取得時のN+1を防ぐ:withCount()・withSum()
    1. よくあるNGパターン
    2. 推奨:withCount() によるサブクエリ集計
  6. 開発・テスト環境でN+1を完全撲滅する:Model::preventLazyLoading()
    1. AppServiceProviderでの設定方法
    2. 動作の仕組み
    3. 本番環境でもログ通知だけ行いたい場合
  7. N+1問題を検知・可視化するおすすめツール
    1. 1. Laravel Debugbar
    2. 2. Laravel Telescope / Laravel Pulse
  8. 実務で頻発するN+1の落とし穴・アンチパターン 4選
    1. ① モデルのアクセサ(Attribute)内でリレーションにアクセスしている
    2. ② API Resource(JsonResource)での無防備な参照
    3. ③ Bladeテンプレート内でリレーションメソッド(括弧あり)を呼んでいる
    4. ④ Eloquentリレーションの基本理解とクエリ最適化
  9. まとめ:N+1問題を防ぐベストプラクティス
  10. 関連記事

【早見表】N+1問題の解決パターン

シナリオ / 課題 推奨メソッド・手法 コード例
クエリ発行時にリレーションを一括取得 with()(Eager Loading) User::with('posts')->get();
すでに取得済みのインスタンスに後から読み込む load() / loadMissing()(Lazy Eager Loading) $user->loadMissing('profile');
ネストした関連データ(親→子→孫)を取得 ドット記法(. User::with('posts.comments.user')->get();
リレーション先の件数のみを取得 withCount() User::withCount('posts')->get();
リレーション先を条件で絞り込んで取得 クロージャ指定 User::with(['posts' => fn($q) => $q->where('is_published', true)])->get();
開発・テスト環境でN+1を完全防止・検知 Model::preventLazyLoading() Model::preventLazyLoading(! app()->isProduction());

N+1問題とは?なぜ発生するのか(仕組みとSQL挙動)

N+1問題とは、「親データを取得する1回のクエリ」に対して、関連する子データを取得するために「N回(親レコードの件数分)の追加クエリ」がループ内で逐次発行されてしまう現象です。

LaravelのEloquent ORMは、デフォルトで遅延ローディング(Lazy Loading)を採用しています。プロパティとしてリレーションにアクセスされたタイミングではじめてSQLを実行してデータを取得するため、意識せずにループを回すとクエリ数が爆発します。

N+1問題が発生する具体例コード

// コントローラー等でユーザー一覧(10件)を取得
$users = User::limit(10)->get(); // 1回目のクエリ

// Bladeテンプレートや処理ループで各ユーザーの投稿一覧を参照
foreach ($users as $user) {
    echo $user->name . 'の投稿数: ';
    foreach ($user->posts as $post) { // ここで毎回クエリが実行される!(10回)
        echo $post->title;
    }
}

実際にデータベースへ発行されるSQL

上記コードを実行した際、データベースには以下の合計11回(1 + 10回)のクエリが発行されます。

-- ① 親レコード(ユーザー10件)を取得するクエリ(1回)
SELECT * FROM `users` LIMIT 10;

-- ② ループ内で各ユーザーの投稿を取得するクエリ(10回)
SELECT * FROM `posts` WHERE `posts`.`user_id` = 1;
SELECT * FROM `posts` WHERE `posts`.`user_id` = 2;
SELECT * FROM `posts` WHERE `posts`.`user_id` = 3;
...
SELECT * FROM `posts` WHERE `posts`.`user_id` = 10;

もしユーザーが100件あれば101回、1,000件あれば1,001回のSQLが発行されます。データベースとのネットワーク往復(RTT)とクエリ解析のオーバーヘッドが積み重なり、深刻なパフォーマンスボトルネックとなります。


解決策1:with() によるEager Loading(事前一括取得)

N+1問題を解決する基本は、クエリ実行時にあらかじめリレーション先のデータをまとめて取得するイーガーローディング(Eager Loading)です。Laravelでは with() メソッドを使用します。

基本構文

// with() にリレーション名を指定
$users = User::with('posts')->limit(10)->get();

foreach ($users as $user) {
    // すでに取得済みのため、新たなクエリは一切発行されない
    foreach ($user->posts as $post) {
        echo $post->title;
    }
}

with() で発行されるSQL

with() を使用すると、Laravelは IN 句を用いて関連データを一括取得します。発行されるクエリはわずか2回に削減されます。

-- ① ユーザー10件を取得
SELECT * FROM `users` LIMIT 10;

-- ② 取得したユーザーID(1〜10)をまとめてIN句で一度に取得
SELECT * FROM `posts` WHERE `posts`.`user_id` IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

Laravel内部で取得した親モデルと子モデルを自動的にマッピングしてコレクションにセットするため、PHP側でのメモリ結合も極めて高速に行われます。

複数のリレーションを同時にイーガーローディングする

配列で複数のリレーション名を渡すことで、複数の関連モデルを同時に取得できます。

$users = User::with(['posts', 'profile', 'roles'])->get();

ネストしたリレーションを取得する(ドット記法)

「ユーザー」→「投稿」→「コメント」→「コメント投稿者」のように、深い階層のリレーションもドット記法(.)で一括取得できます。

// 投稿とそのコメント、さらにコメントを書いたユーザーまで一括ロード
$posts = Post::with('comments.user')->get();

取得カラムを限定してメモリを節約する

大量のデータを扱う場合、必要なカラムだけを指定して取得できます。注意点として、親モデルと紐付ける外部キー(ここでは user_id)と主キー(id)を必ず含める必要があります。これらが抜けるとリレーションのマッピングに失敗します。

// 正しい例: id と user_id を必ず含める
$users = User::with('posts:id,user_id,title,created_at')->get();

条件付きイーガーローディング(制約の追加)

リレーション先のデータを特定の条件で絞り込んでロードしたい場合は、連想配列と無名関数(クロージャ / アロー関数)を渡します。

$users = User::with([
    'posts' => function ($query) {
        $query->where('is_published', true)
              ->orderBy('published_at', 'desc');
    },
    'comments' => fn($query) => $query->where('status', 'approved'),
])->get();

解決策2:load() / loadMissing() によるLazy Eager Loading(遅延一括取得)

親モデルのインスタンスをすでに取得した後に、条件分岐などの都合でリレーションを後から一括ロードしたい場合は Lazy Eager Loadingload() または loadMissing())を使用します。

load() の使い方

$users = User::all();

// ある特定の条件を満たす場合のみリレーションを一括ロード
if ($shouldLoadPosts) {
    $users->load('posts');
}

loadMissing() で不要な再取得を防止する

load() はすでにロード済みのリレーションであっても再度SQLを発行して上書き取得します。一方、loadMissing()まだロードされていない場合のみクエリを発行するため、より安全で効率的です。

// posts がまだロードされていない場合のみクエリを実行
$users->loadMissing(['posts', 'profile']);

件数・集計取得時のN+1を防ぐ:withCount()・withSum()

リレーション先の「件数」や「合計値」だけを表示したい場合、モデル全体をロードして count() を呼ぶのは大きなアンチパターンです。

よくあるNGパターン

// NG: 投稿全件のモデルインスタンスをメモリに展開してしまう
$users = User::with('posts')->get();
foreach ($users as $user) {
    echo $user->posts->count(); // メモリを大量消費
}

// 最悪のNG: ループ内でクエリを発行(N+1問題)
$users = User::all();
foreach ($users as $user) {
    echo $user->posts()->count(); // ループのたびに SELECT COUNT(*) が走る!
}

推奨:withCount() によるサブクエリ集計

withCount() を使用すると、単一のSQL内でサブクエリとして件数を計算し、{リレーション名}_count というプロパティに自動設定してくれます。

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

foreach ($users as $user) {
    // 追加クエリなし・モデル展開なしで件数を取得できる
    echo $user->posts_count;
}

発行されるSQL:

SELECT `users`.*, 
  (SELECT COUNT(*) FROM `posts` WHERE `users`.`id` = `posts`.`user_id`) AS `posts_count` 
FROM `users`;

同様に、Laravelでは withSum(), withAvg(), withMin(), withMax() も用意されています。

// 注文ごとの合計金額をサブクエリで取得
$orders = Order::withSum('items', 'price')->get();
echo $orders->first()->items_sum_price;

開発・テスト環境でN+1を完全撲滅する:Model::preventLazyLoading()

どんなに気をつけていても、コードレビューで見落とされたり、新機能の追加時にN+1が混入したりすることがあります。これを仕組みとして根本から防止できるのが、Laravelの Model::preventLazyLoading() です。

AppServiceProviderでの設定方法

app/Providers/AppServiceProvider.phpboot() メソッドに以下を追加します。

namespace App\Providers;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        // 本番環境(production)以外ではLazy Loadingを完全禁止(例外をスロー)
        Model::preventLazyLoading(! $this->app->isProduction());
    }
}

動作の仕組み

この設定を有効にすると、with() などのEager Loadingを行わずにリレーションプロパティにアクセスした場合、Laravelは即座に Illuminate\Database\Eloquent\LazyLoadingViolationException 例外を発生させて処理を停止します。

Illuminate\Database\Eloquent\LazyLoadingViolationException
Attempted to lazy load [posts] on model [App\Models\User] but lazy loading is disabled.

ローカル開発時や自動テスト(PHPUnit / Pest)実行時に即座にエラーとなって気付けるため、N+1問題が本番環境に混入するリスクをゼロに抑えることができます。

本番環境でもログ通知だけ行いたい場合

例外で画面を真っ白にしたくはないが、本番環境で発生したN+1をログに残したい場合は、handleLazyLoadingViolationUsing を組み合わせます。

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Log;

public function boot(): void
{
    Model::preventLazyLoading(! $this->app->isProduction());

    // 本番環境でN+1が発生した場合はログに記録する
    if ($this->app->isProduction()) {
        Model::handleLazyLoadingViolationUsing(function ($model, $relation) {
            Log::warning("Lazy loading [{$relation}] on model [" . get_class($model) . "].");
        });
    }
}

N+1問題を検知・可視化するおすすめツール

コードの実行状況やクエリ発行数を可視化するための代表的なツールを導入しておくと、パフォーマンスチューニングが非常にスムーズになります。

1. Laravel Debugbar

画面下部に実行されたSQLクエリの一覧、実行時間、重複クエリ(N+1)の警告バッジを表示してくれる定番パッケージです。詳細な導入手順は Laravel Debugbarの使い方完全ガイド をご覧ください。

2. Laravel Telescope / Laravel Pulse


実務で頻発するN+1の落とし穴・アンチパターン 4選

実務の現場で特に見落とされやすい4つのアンチパターンと、その対策方法を紹介します。

① モデルのアクセサ(Attribute)内でリレーションにアクセスしている

// NG: アクセサ内で別モデルをLazy Load
protected function latestPostTitle(): Attribute
{
    return Attribute::make(
        get: fn () => $this->posts()->latest()->first()?->title,
    );
}

対策: モデルのアクセサ内ではリレーションクエリを直接呼ばず、コントローラー側でEager Loadingしたデータ($this->posts->first() など)を参照するか、専用のクエリで取得するように設計を見直します。

② API Resource(JsonResource)での無防備な参照

// NG: コントローラー側で with('profile') を忘れるとN+1が発生
public function toArray(Request $request): array
{
    return [
        'id' => $this->id,
        'name' => $this->name,
        'profile' => new ProfileResource($this->profile),
    ];
}

// 改善: whenLoaded() を使ってロード済みの時だけ含める
public function toArray(Request $request): array
{
    return [
        'id' => $this->id,
        'name' => $this->name,
        'profile' => new ProfileResource($this->whenLoaded('profile')),
    ];
}

③ Bladeテンプレート内でリレーションメソッド(括弧あり)を呼んでいる

Blade内では括弧なしのプロパティアクセスを徹底しましょう。

④ Eloquentリレーションの基本理解とクエリ最適化

リレーションの定義方法や基本的なデータ操作については、Laravel Eloquentの基本ガイドHasManyリレーション活用ガイド、条件絞り込みには whereHasメソッド解説 、安全なデータ整合性を保つ更新処理には Laravel トランザクションの使い方完全ガイド 、リレーションの定義方法や種類については Laravel Eloquentリレーション全種類まとめ|hasMany・belongsToの違いと正しい定義方法を徹底解説 も合わせて参考にしてください。


まとめ:N+1問題を防ぐベストプラクティス

LaravelにおけるN+1問題は、仕組みの理解と適切なツールの導入によって確実に防ぐことができます。

開発の初期段階から preventLazyLoading を設定し、高速でスケーラブルなLaravelアプリケーションを構築していきましょう。

レン (Wren)

こんにちは。レンです。

Laravelのコードの森に住んでいる、小さな案内役です。
ルーティングの枝やクラスの影を歩きながら、コードの流れや仕組みを眺めています。

このサイトでは、Laravelの基本から実装のコツまで、開発で役立つポイントを静かに整理しています。
難しいことを増やすのではなく、コードの見通しが少し良くなるヒントを届けるのが役目です。

「この処理はどこに書くのがいいのか」
「Laravelではどう考えると整理できるのか」

そんな疑問に、小さなメモを残すような気持ちで記事を書いています。

コードを書いている途中で迷ったとき、
このサイトが少し立ち止まって整理できる場所になればうれしいです。

レン (Wren)をフォローする

コメント