laravel-query-detectorでN+1問題を検出・改善するハンズオン
概要
Laravelで一覧画面を作っていると、いつの間にか発行SQLが数十〜数百本に膨れ上がる「N+1問題」に遭遇します。本記事では beyondcode/laravel-query-detector を使ってN+1を自動検出し、with() によるEager Loadingで改善するまでをハンズオン形式でまとめます。再現コード・検出の見え方・改善後の確認方法まで通しで追えるようにしています。
環境
- PHP 8.3 以上
- Laravel 12 / 13 系
- beyondcode/laravel-query-detector 2.x
- 開発環境(
APP_ENV=local)での利用を前提とします
laravel-query-detector は開発用パッケージです。本番では無効化する前提で require-dev に入れます。
なお、本記事のコード・設計方針は執筆時点の最新環境でもそのまま有効です。Eloquent の with() / withCount() / $with / DB::getQueryLog() のAPIは変わっておらず、laravel-query-detector も 2.x で vendor:publish のプロバイダ名(BeyondCode\QueryDetector\QueryDetectorServiceProvider)と config キー(enabled / threshold / output / except)が 1.x と同一のため、以下の手順はそのまま踏襲できます。
発生した問題
ブログ記事の一覧画面で、各記事の著者名を表示するコードを考えます。
// Controller $posts = Post::all();
// Blade
@foreach ($posts as $post)
<li>{{ $post->title }} / {{ $post->user->name }}</li>
@endforeach
一見問題なさそうですが、$post->user にアクセスするたびに users テーブルへのクエリが1本走ります。記事が100件あれば、posts を取る1本と各記事の user を取る100本で合計101本のSQLが発行されます。これがN+1問題です。件数が増えるほどレスポンスが悪化し、DB負荷も跳ね上がります。
問題は「動いてしまう」こと。開発中は記事が数件しかなく体感できず、データが増えた本番で初めて遅延として顕在化しがちです。だからこそ、開発段階で機械的に検出する仕組みが要ります。
原因
ORM(Eloquent)はリレーションを 遅延ロード(Lazy Loading) します。$post->user に初めてアクセスした瞬間にクエリを発行する挙動のため、ループ内でアクセスすると件数分だけクエリが走ります。便利さと引き換えに、コードの見た目からはSQL本数が読み取りにくいのが厄介な点です。
対応方法
laravel-query-detector を導入します。同一リクエスト内で「同じリレーションに対して閾値以上のクエリが発行された」場合に警告を出してくれるパッケージです。
composer require beyondcode/laravel-query-detector --dev php artisan vendor:publish --provider="BeyondCode\QueryDetector\QueryDetectorServiceProvider"
config/querydetector.php の主な項目です。
return [
// 有効化(本番では必ず false)
'enabled' => env('QUERY_DETECTOR_ENABLED', false),
// 同一リレーションで何回クエリが走ったら警告するか
'threshold' => 1,
// 検出結果の出力先
'output' => [
\BeyondCode\QueryDetector\Outputs\Alert::class,
\BeyondCode\QueryDetector\Outputs\Log::class,
],
// 特定リクエストを無視したい場合のパターン
'except' => [
// '/admin*',
],
];
.env で開発時のみ有効化します。
QUERY_DETECTOR_ENABLED=true
この状態で一覧画面を開くと、「Post で user リレーションがN+1で呼ばれている」旨の警告と修正案(Post::with('user') を使え)が出力されます。出力先を Log にしておけば storage/logs/laravel.log にも残るので、API開発やテスト経由でも拾えます。
実装例
検出された箇所をEager Loadingで直します。
// Before: N+1発生
$posts = Post::all();
// After: 著者をまとめて取得(クエリは2本に収束)
$posts = Post::with('user')->get();
ネストしたリレーションや複数リレーションもまとめて指定できます。
$posts = Post::with([
'user',
'comments.user',
])->get();
一覧の各行で「コメント件数」だけ欲しいケースは、リレーション本体を取らず withCount() を使う方が軽くなります。
$posts = Post::withCount('comments')->get();
// Blade: $post->comments_count で参照できる
常に一緒に読むリレーションが決まっているなら、モデル側に $with を定義する手もあります(ただし常時JOIN相当の負荷になるため、本当に毎回必要なものに限定します)。
class Post extends Model
{
protected $with = ['user'];
}
確認方法
改善できたかは、発行SQL本数で確認します。検出警告が消えることが一次確認ですが、数値でも裏を取ります。
use Illuminate\Support\Facades\DB;
DB::enableQueryLog();
$posts = Post::with('user')->get();
foreach ($posts as $post) {
$post->user->name;
}
logger()->info('query count: ' . count(DB::getQueryLog()));
// Before: 101 → After: 2 になっていれば成功
テストで本数を固定しておくと、将来のリグレッションも防げます。
public function test_一覧取得でN1が起きないこと(): void
{
Post::factory()->count(10)->create();
DB::enableQueryLog();
$this->get('/posts')->assertOk();
// posts 1本 + users 1本 = 2本以内に収まること
$this->assertLessThanOrEqual(2, count(DB::getQueryLog()));
}
注意点
- 本番で有効化しない。
enabledはenv()経由にし、本番の.envではfalseを徹底します。検出処理自体がオーバーヘッドになります。 thresholdを1にすると厳しすぎて誤検知が増えることがあります。まず1で洗い出し、意図的な少数クエリはexceptで除外するなど運用で調整します。- Eager Loadingは「使うリレーションだけ」指定します。使わないリレーションまで
with()すると、今度は無駄なクエリ・メモリ消費を招きます。 $with(モデル定義)は全クエリに効くため、重い一覧ではwith()を都度書く方が安全です。
まとめ
- N+1はLazy Loadingの副作用で、データ量が増える本番で初めて牙をむく
laravel-query-detectorをrequire-devで入れ、開発時だけ有効化すると機械的に検出できる- 検出箇所は
with()/withCount()で潰し、DB::getQueryLog()の本数とテストで裏を取る - 「開発段階で検出 → Eager Loadingで改善 → テストで固定」までを習慣化すると、N+1は再発しにくくなる
