Eloquent ORMで効率よくクエリを組み立てる実践パターン(遅くしない・壊さない・読みやすい)

Eloquent ORMで効率よくクエリを組み立てる実践パターン(遅くしない・壊さない・読みやすい)

Eloquentのクエリビルディングは「書きやすい」のが強みだが、何も考えずに書くとN+1、不要なカラム取得、肥大したJOIN、無駄なOR条件、巨大IN、ページングの遅延などが起きやすい。効率化の軸はシンプルで、(1) 取得する行と列を最小化、(2) 関連は eager load と制約で制御、(3) 条件分岐はwhen/スコープで整理、(4) 集計・存在判定は専用メソッド(exists/withCount)へ寄せる、(5) 大量データはchunk/cursorで流す、の5つでほぼ網羅できる。さらに「発生条件(どんな書き方で遅くなるか)」を知っておくと、同じ事故を繰り返さなくなる。

効率を落とす発生条件(典型パターン)

・一覧表示で関連をループ内参照し、N+1クエリが発生
・select * のまま巨大テーブルから不要列まで取得
・where/OR条件を雑に積み、インデックスが効かない形になる
・count() をループ内で多用し、同じ集計を何度も叩く
・大量データを get() で一括取得してメモリが膨らむ
・ページングで offset が深くなり、後半ページが極端に遅くなる
・JSON/テキスト大列を毎回取得し、ネットワークとメモリを浪費

まず“列”を絞る:select() と必要な関係だけを取る

一覧で必要なのは「表示に使う列だけ」が基本。関連も必要分のみ。

$users = User::query()
    ->select(['id', 'name', 'email', 'created_at'])
    ->where('is_active', true)
    ->latest()
    ->paginate(20);

関連を読むなら、関連側もselectで絞る(ただし外部キーは必須)。

$posts = Post::query()
    ->select(['id', 'user_id', 'title', 'published_at'])
    ->with(['user:id,name']) // user_id があるから結べる
    ->latest('published_at')
    ->paginate(20);

selectで削りすぎて「結合キーが無い」状態にすると、関連が取れない/意図しない追加クエリが出るので、外部キーは残す。

N+1を止める:with() と制約付き eager load

ループ内で関連へ触ると、関連の分だけクエリが増えるのがN+1。
ダメな例(発生条件)。

$posts = Post::latest()->take(50)->get();

foreach ($posts as $post) {
    echo $post->user->name; // N+1
}

対処:withでまとめて取る。

$posts = Post::query()
    ->with('user')
    ->latest()
    ->take(50)
    ->get();

さらに効率化:関連側も絞り、必要条件だけを読む。

$posts = Post::query()
    ->with(['user' => function ($q) {
        $q->select(['id', 'name']); // id必須
    }])
    ->where('status', 'public')
    ->latest()
    ->take(50)
    ->get();

条件分岐は when() / スコープで“増やしやすく・壊れにくく”する

検索フォームの条件をifで積むと読みにくくなりがち。whenで一本化すると保守が楽。

$query = Post::query()
    ->select(['id', 'user_id', 'title', 'status', 'published_at'])
    ->with(['user:id,name']);

$query->when($request->filled('q'), function ($q) use ($request) {
    $keyword = $request->input('q');
    $q->where(function ($w) use ($keyword) {
        $w->where('title', 'like', "%{$keyword}%")
          ->orWhere('body', 'like', "%{$keyword}%");
    });
});

$query->when($request->filled('status'), function ($q) use ($request) {
    $q->where('status', $request->input('status'));
});

$posts = $query->latest('published_at')->paginate(20);

スコープに寄せるとさらに管理しやすい。

// Post.php
public function scopePublic($q)
{
    return $q->where('status', 'public');
}

public function scopeKeyword($q, ?string $keyword)
{
    if (!$keyword) return $q;

    return $q->where(function ($w) use ($keyword) {
        $w->where('title', 'like', "%{$keyword}%")
          ->orWhere('body', 'like', "%{$keyword}%");
    });
}

// 利用側
$posts = Post::query()
    ->public()
    ->keyword($request->input('q'))
    ->with('user:id,name')
    ->paginate(20);

存在判定・件数・合計は専用メソッドへ(exists / withCount / withSum)

発生条件:
・if (Model::where(…)->count() > 0) のように、存在確認にcountを使う(無駄)
対処:exists を使う。

$hasUnread = Notification::query()
    ->where('user_id', $userId)
    ->whereNull('read_at')
    ->exists();

関連件数を一覧に出したいとき、ループ内countは避け、withCount。

$users = User::query()
    ->withCount('posts')
    ->orderByDesc('posts_count')
    ->paginate(20);

合計も同様に withSum / withAvg を使う(対応バージョンにより利用可)。

$users = User::query()
    ->withSum('orders', 'total_amount')
    ->orderByDesc('orders_sum_total_amount')
    ->paginate(20);

関連で絞り込む:whereHas / withWhereHas を使い分ける

「関連が条件を満たす親だけ欲しい」は whereHas。

$users = User::query()
    ->whereHas('orders', function ($q) {
        $q->where('status', 'paid');
    })
    ->paginate(20);

「絞り込みもして、関連も同じ条件で読みたい」場合は withWhereHas が便利。

$users = User::query()
    ->withWhereHas('orders', function ($q) {
        $q->where('status', 'paid')->latest()->limit(5);
    })
    ->paginate(20);

発生条件:whereHasだけで絞ったのに、with(‘orders’)で全注文を読むと、結局データ量が増えて遅くなる。

大量データは get() しない:chunkById / cursor で流す

発生条件:
・数十万件を get() してメモリが死ぬ、処理時間も伸びる
対処:chunkByIdで分割処理(更新・バッチ向き)。

User::query()
    ->where('is_active', true)
    ->orderBy('id')
    ->chunkById(1000, function ($users) {
        foreach ($users as $user) {
            // バッチ処理
        }
    });

読み取り中心なら cursor で1件ずつストリーム。

foreach (User::query()->where('is_active', true)->cursor() as $user) {
    // 逐次処理
}

chunkByIdは「途中で増えた行」にも強い(offset chunkより事故が少ない)。

ページングが遅い発生条件:深いoffset(後半ページ)

発生条件:
・offsetが大きいページ(例:1000ページ目)で遅くなる
対処:キーセットページング(cursorPaginate)を使う。

$posts = Post::query()
    ->select(['id', 'title', 'published_at'])
    ->where('status', 'public')
    ->orderByDesc('published_at')
    ->cursorPaginate(20);

cursorPaginateは「任意ページへジャンプ」には向かないが、無限スクロール/次へ次へ型に強い。

“重い処理”をクエリに寄せすぎない:pluck / value / lazyな取得

必要なのがID配列だけなら pluck。

$postIds = Post::query()
    ->where('status', 'public')
    ->latest('published_at')
    ->limit(100)
    ->pluck('id');

1つだけ欲しいなら value。

$email = User::query()->whereKey($userId)->value('email');

モデル生成(Hydration)自体が不要な場面では、こうしたメソッドが速い。

サンプル:検索API用の“安全で速い”クエリ組み立て

要点:selectで絞る、whenで条件追加、関連は必要最小、件数はwithCount、ページングは用途で選ぶ。

$posts = Post::query()
    ->select(['id', 'user_id', 'title', 'status', 'published_at'])
    ->with(['user:id,name'])
    ->withCount('comments')
    ->when($request->filled('q'), function ($q) use ($request) {
        $kw = $request->input('q');
        $q->where(function ($w) use ($kw) {
            $w->where('title', 'like', "%{$kw}%")
              ->orWhere('body', 'like', "%{$kw}%");
        });
    })
    ->when($request->filled('user_id'), fn($q) => $q->where('user_id', $request->input('user_id')))
    ->when($request->filled('status'), fn($q) => $q->where('status', $request->input('status')))
    ->orderByDesc('published_at')
    ->paginate(20);

チェックリスト(遅くなる前に確認する)

・一覧で関連を触るなら with() を入れてN+1を潰したか
・select * を避け、必要列だけに絞ったか(特に大きいTEXT/JSON)
・関連も必要な列だけに絞ったか(関連selectでは外部キーを落とさない)
・存在確認は count ではなく exists を使っているか
・件数/合計はループ内集計ではなく withCount/withSum を使っているか
・大量データは get() ではなく chunkById/cursor で流しているか
・深いページングが必要なら cursorPaginate を検討したか
・whereHasで絞ったのに withで“全関連”を取っていないか(データ量が増える)