Eloquent ORMで効率よくクエリを組み立てる実践パターン(遅くしない・壊さない・読みやすい)
- 作成日 2026.02.03
- その他
Eloquentのクエリビルディングは「書きやすい」のが強みだが、何も考えずに書くとN+1、不要なカラム取得、肥大したJOIN、無駄なOR条件、巨大IN、ページングの遅延などが起きやすい。効率化の軸はシンプルで、(1) 取得する行と列を最小化、(2) 関連は eager load と制約で制御、(3) 条件分岐はwhen/スコープで整理、(4) 集計・存在判定は専用メソッド(exists/withCount)へ寄せる、(5) 大量データはchunk/cursorで流す、の5つでほぼ網羅できる。さらに「発生条件(どんな書き方で遅くなるか)」を知っておくと、同じ事故を繰り返さなくなる。
- 1. 効率を落とす発生条件(典型パターン)
- 2. まず“列”を絞る:select() と必要な関係だけを取る
- 3. N+1を止める:with() と制約付き eager load
- 4. 条件分岐は when() / スコープで“増やしやすく・壊れにくく”する
- 5. 存在判定・件数・合計は専用メソッドへ(exists / withCount / withSum)
- 6. 関連で絞り込む:whereHas / withWhereHas を使い分ける
- 7. 大量データは get() しない:chunkById / cursor で流す
- 8. ページングが遅い発生条件:深いoffset(後半ページ)
- 9. “重い処理”をクエリに寄せすぎない:pluck / value / lazyな取得
- 10. サンプル:検索API用の“安全で速い”クエリ組み立て
- 11. チェックリスト(遅くなる前に確認する)
効率を落とす発生条件(典型パターン)
・一覧表示で関連をループ内参照し、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で“全関連”を取っていないか(データ量が増える)
-
前の記事
Laravelで認証を実装する方法:SanctumとPassportの使い分けと実装手順 2026.02.02
-
次の記事
記事がありません
コメントを書く