【実践コード付き】Laravel Eloquentサブクエリ完全ガイド|addSelect・whereExists・N+1対策

基本文法・構文ガイド

Laravelでデータベースを扱う際、Eloquent ORMは直感的で洗練された記述を可能にしてくれます。しかし、管理画面や一覧画面で「ユーザーごとに最新の注文日時を表示したい」「特定の条件を満たす関連レコードが存在する親データだけを抽出したい」「関連モデルの最新データ順にソートしたい」といった要件に直面したとき、クエリの設計に悩むエンジニアは少なくありません。

安易に with()(Eager Loading)を使って全関連モデルをメモリに読み込むと、データ量増加に伴いメモリ枯渇(Memory Limit Exceeded)やレスポンス低下を引き起こします。一方でループ内で関連モデルを呼び出すと典型的なN+1問題が発生します。

この問題を一発で解決するのがEloquentのサブクエリ機能(addSelectwhereExistswhereSuborderByサブクエリ)です。本記事では、Laravel 10/11/12に対応した実践コード例EXPLAIN実行計画によるパフォーマンス比較、および複合インデックス設計の要点を網羅して徹底解説します。


【目的別】Eloquentサブクエリ実践コード早見表

実装したい要件に合わせて、最適なサブクエリメソッドを選択してください。

① 最新データの取得:addSelect()

関連モデルの最新日時や特定カラムを1クエリで親モデルの属性にバインド。

$users = User::addSelect([
    'latest_order_at' => Order::select('created_at')
        ->whereColumn('user_id', 'users.id')
        ->latest()->take(1)
])->get();

② 存在チェック絞り込み:whereExists()

条件に合致する関連レコードが存在する親レコードのみを高速抽出。

$users = User::whereExists(function ($q) {
    $q->select(DB::raw(1))->from('orders')
      ->whereColumn('orders.user_id', 'users.id')
      ->where('orders.status', 'paid');
})->get();

③ 関連カラム順ソート:orderBy()

JOINを使わず関連モデルの最新値や集計値順にページネーションソート。

$users = User::orderByDesc(
    Order::select('created_at')
        ->whereColumn('user_id', 'users.id')
        ->latest()->take(1)
)->paginate(20);

④ 大量データ絞り込み:whereIn()

PHP側でpluck配列を作らず、SQLサブクエリでプレースホルダー枯渇を防止。

$users = User::whereIn('id', function ($q) {
    $q->select('user_id')->from('vip_logs')
      ->where('score', '>=', 80);
})->get();

1. addSelectを活用した関連モデル最新データ・集計値の一括取得

Laravelアプリケーションで頻出するシナリオとして、「ユーザー一覧画面で、各ユーザーの最新ログイン日時や直近の注文金額を表示したい」というケースがあります。

よくある失敗パターン:Eager Loading(with)によるメモリ肥大化

Eager Loading(with('orders'))はN+1問題を解決する標準機能ですが、「最新の1件だけが欲しい」場合でも該当ユーザーの過去全件の注文モデルをPHPメモリ上にハイドレーション(インスタンス化)してしまいます。

// ❌ 非効率な例:全件取得してPHP側で最新1件を取り出す
$users = User::with('orders')->paginate(50);

foreach ($users as $user) {
    // ユーザーに紐づく過去数百件のOrderモデルがメモリに展開されてしまう!
    $latestOrder = $user->orders->sortByDesc('created_at')->first();
    echo $latestOrder?->created_at;
}

ユーザー1人あたり平均100件の注文がある場合、50人のユーザーを表示するだけで 50 × 100 = 5,000件のEloquentモデルが生成され、メモリ消費量が数十MB〜数百MBに跳ね上がります。

推奨パターン:addSelectによるサブクエリ選択

addSelect にEloquentクエリビルダインスタンスを渡すことで、メインテーブルのクエリに相関サブクエリ(Correlated Subquery)を埋め込み、1クエリ・最小メモリで最新値を取得できます。

use App\Models\User;
use App\Models\Order;

$users = User::query()
    ->addSelect([
        // 最新注文日時を取得して 'latest_order_at' カラムとして追加
        'latest_order_at' => Order::select('created_at')
            ->whereColumn('orders.user_id', 'users.id')
            ->latest('created_at')
            ->take(1),

        // 最新注文の合計金額を取得
        'latest_order_total' => Order::select('total_amount')
            ->whereColumn('orders.user_id', 'users.id')
            ->latest('created_at')
            ->take(1),

        // 過去の注文合計回数(集計値)を取得
        'orders_count' => Order::selectRaw('count(*)')
            ->whereColumn('orders.user_id', 'users.id'),
    ])
    // 取得した動的属性に型キャストを適用
    ->withCasts([
        'latest_order_at' => 'datetime',
        'latest_order_total' => 'integer',
        'orders_count' => 'integer',
    ])
    ->paginate(20);

Bladeビューでの利用方法

サブクエリで選択したエイリアス名(latest_order_at など)は、モデルの通常属性と全く同じように透過的にアクセス可能です。

<table class="table">
    <thead>
        <tr>
            <th>ユーザー名</th>
            <th>最新注文日</th>
            <th>最新注文金額</th>
            <th>注文総数</th>
        </tr>
    </thead>
    <tbody>
        @foreach ($users as $user)
            <tr>
                <td>{{ $user->name }}</td>
                <td>{{ $user->latest_order_at?->format('Y/m/d H:i') ?? '注文なし' }}</td>
                <td>{{ $user->latest_order_total ? '¥' . number_format($user->latest_order_total) : '-' }}</td>
                <td>{{ number_format($user->orders_count) }} 回</td>
            </tr>
        @endforeach
    </tbody>
</table>

ローカルスコープへのカプセル化(保守性の向上)

コントローラに複雑なサブクエリロジックを直接書くのではなく、Userモデル内にローカルスコープ(Local Scope)として定義しておくと再利用性とテスト容易性が飛躍的に向上します。

// app/Models/User.php
namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;

class User extends Model
{
    /**
     * 最新注文情報と注文総数を付与するスコープ
     */
    public function scopeWithLatestOrderSummary(Builder $query): Builder
    {
        return $query->addSelect([
            'latest_order_at' => Order::select('created_at')
                ->whereColumn('orders.user_id', 'users.id')
                ->latest('created_at')
                ->take(1),
            'latest_order_total' => Order::select('total_amount')
                ->whereColumn('orders.user_id', 'users.id')
                ->latest('created_at')
                ->take(1),
        ])->withCasts([
            'latest_order_at' => 'datetime',
            'latest_order_total' => 'integer',
        ]);
    }
}

// 利用側(ControllerやRepository)
$users = User::query()->withLatestOrderSummary()->paginate(20);

2. whereExists / whereSub による高度な条件絞り込み

サブクエリを使ったレコードのフィルタリング(絞り込み)は、複雑なビジネスロジックをSQLエンジン側で高速に処理するための強力な武器です。

whereExists と whereHas の使い分け

Laravelには関連モデルの存在確認用メソッドとして whereHas() が用意されています。whereHas の内部実装も EXISTS サブクエリを生成しますが、複数テーブルの結合やインデックスをピンポイントで効かせたい複雑な条件では whereExists() を明示的に使うことでクエリの最適化が図れます

use App\Models\User;
use Illuminate\Support\Facades\DB;

// 「直近30日以内に10,000円以上の決済が完了した注文があるユーザー」を抽出
$premiumUsers = User::query()
    ->whereExists(function ($query) {
        $query->select(DB::raw(1))
            ->from('orders')
            ->whereColumn('orders.user_id', 'users.id')
            ->where('orders.status', 'completed')
            ->where('orders.total_amount', '>=', 10000)
            ->where('orders.created_at', '>=', now()->subDays(30));
    })
    ->get();
💡 whereExistsのポイント:
select(DB::raw(1)) を指定することで、データベースエンジンは条件を満たすレコードが1行見つかった時点でスキャンを終了し(Short-circuit evaluation)、無駄なデータ転送を一切行いません。

whereSub(スカラーサブクエリによる比較)

「カテゴリ平均価格より高い商品」や「サイト平均購入額を上回る顧客」のように、動的に集計したスカラー値(単一の値)と比較するクエリもサブクエリで簡潔に記述できます。

use App\Models\Product;

// 同一カテゴリ内の平均価格を上回るプレミアム商品を抽出
$expensiveProducts = Product::query()
    ->where('price', '>', function ($query) {
        $query->selectRaw('AVG(p2.price)')
            ->from('products as p2')
            ->whereColumn('p2.category_id', 'products.category_id');
    })
    ->get();

whereIn と サブクエリの連携(大量データ対策)

関連モデルのID一覧で親モデルを絞り込む際、PHP側で pluck('id') して配列を渡すコードがよく見られますが、これは重大なパフォーマンス障害(SQLプレースホルダー上限超過)の温床になります。

// ❌ 危険:レコードが数万件あるとPDO Prepared Statement上限(65,535)やメモリがパンクする
$userIds = Order::where('status', 'fraud')->pluck('user_id'); // 数万件の配列
$users = User::whereIn('id', $userIds)->get();

// ⭕ 安全&高速:サブクエリを直接whereInに渡す(メモリ消費ゼロ、1クエリで完結)
$users = User::query()
    ->whereIn('id', function ($query) {
        $query->select('user_id')
            ->from('orders')
            ->where('status', 'fraud');
    })
    ->get();

3. orderBy サブクエリによる関連データ順ソート

一覧画面で「最新注文日時が新しい順」や「総レビュー評価点が高い順」にページネーション表示したい場合、従来の leftJoin を使う手法では以下のような問題が発生していました:

  • メインテーブルのカラム名(idcreated_at)が衝突・上書きされる
  • 1対多リレーションの場合に行が増殖し、GROUP BYDISTINCT が必要になってクエリオプティマイザの負荷が増大する

Eloquentの orderBy / orderByDesc にサブクエリを渡すことで、テーブルをJOINすることなく安全かつ美しく関連順ソートが実現できます。

use App\Models\User;
use App\Models\Order;

// ユーザー一覧を「最新注文日時が新しい順(降順)」でソート
$users = User::query()
    ->orderByDesc(
        Order::select('created_at')
            ->whereColumn('orders.user_id', 'users.id')
            ->latest('created_at')
            ->take(1)
    )
    ->paginate(20);

4. EXPLAIN実行計画によるN+1対策・パフォーマンス徹底比較

各手法における実行特性、クエリ発行回数、およびメモリ消費量の違いを比較表で確認しましょう。

実装手法 クエリ発行数 PHPメモリ消費 実行速度 最適なユースケース
N+1 (ループ内参照) N + 1 回 極めて低速 (数百ms〜数秒) なし(絶対に回避)
Eager Loading (with) 2 回 大 (全関連レコードを生成) 高速 関連レコードの全件・複数カラムを扱う場合
サブクエリ (addSelect) 1 回 最小 (親モデルのみ) 極めて高速 最新値・集計値の表示・ソート・存在確認
JOIN (joinSub / leftJoin) 1 回 高速 大量の複数カラムを一度に結合する場合

EXPLAIN実行計画の解析とインデックス設計

相関サブクエリを高速に動作させるためには、外部キーと並び替え/絞り込みカラムの「複合インデックス(Composite Index)」が必須です。

MySQLやPostgreSQLで EXPLAIN を実行した際、サブクエリ側で DEPENDENT SUBQUERY または Correlated Subquery と表示されます。インデックスが存在しない場合、親テーブルの行数分だけフルテーブルスキャンが発生し急激に遅くなります。

-- EXPLAIN の確認例
EXPLAIN SELECT users.*,
    (SELECT created_at FROM orders WHERE orders.user_id = users.id ORDER BY created_at DESC LIMIT 1) as latest_order_at
FROM users LIMIT 20;

-- ⭕ インデックス適用後:
-- orders テーブルに対するアクセスタイプが 'ref' または 'index' となり、
-- Using index(カバリングインデックス)で超高速に解決される

マイグレーションでの複合インデックス定義

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('orders', function (Blueprint $table) {
            // user_id で絞り込み、created_at で即座に最新1件を特定するための複合インデックス
            $table->index(['user_id', 'created_at'], 'idx_orders_user_created_at');
        });
    }

    public function down(): void
    {
        Schema::table('orders', function (Blueprint $table) {
            $table->dropIndex('idx_orders_user_created_at');
        });
    }
};

5. サブクエリ実装時の注意点とアンチパターン

⚠️ 注意すべき4大アンチパターン

  1. take(1) / LIMIT 1 の付け忘れ: スカラーサブクエリ(SELECT 句内)で2行以上が返ると Subquery returns more than 1 row エラーでSQLがクラッシュします。必ず take(1) を指定してください。
  2. whereColumn のテーブル名省略による曖昧(Ambiguous)エラー: 結合先とメインテーブルで同名カラム(idstatus など)がある場合、必ず orders.user_id のようにテーブル名を明示してください。
  3. 動的属性の型キャスト忘れ: サブクエリで追加したカラムは文字列型で返ってくることが多いため、withCasts(['latest_order_at' => 'datetime']) を併用してCarbonインスタンスとして扱えるようにしましょう。
  4. 無制限のサブクエリ多重ネスト: 1つのSELECT文に10個以上の重い集計サブクエリを詰め込むと、DBオプティマイザの最適化限界を超えることがあります。その場合は定期集約テーブルやキャッシュの活用を検討してください。

6. 関連記事(あわせて読みたいパフォーマンス改善ガイド)

データベースクエリやパフォーマンスのさらなる最適化については、以下の関連記事もぜひ参考にしてください。

7. まとめ

Laravel Eloquentのサブクエリ機能をマスターすることで、複雑なデータ要件に対しても「N+1の防止」「PHPメモリ消費の最小化」「1クエリでの完結」を同時に達成できます。

  • addSelect: 関連モデルの最新日時・集計値を親モデルに直接ロード(ローカルスコープ化でさらに洗練)。
  • whereExists: 不要なデータ転送を省き、存在チェックによる高速絞り込みを実現。
  • orderBy サブクエリ: JOINによる行増殖やカラム重複を回避して安全に関連順ソート。
  • 複合インデックス(user_id, created_at: 相関サブクエリのパフォーマンスを最大限に引き出す必須設計。

一覧画面の表示速度やバッチ処理のメモリ効率に課題を感じている方は、ぜひ本記事の実践パターンを取り入れてみてください。

レン (Wren)

こんにちは。レンです。

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

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

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

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

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

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

コメント