◀ 10.

Laravel Query Builderの基本|バインド・JOIN・N+1とSQL確認

▶

Laravel Query BuilderはSQLの組立てを助けますが、すべての入力やループ内の取得が自動的に安全・高速になるわけではありません。値のバインド、識別子の許可、実行本数、更新処理の影響を分けて確認します。

SQL組立てと実行後の観測、秘密値の取扱いの区別

最初に分けて確認する4点

  1. 値をプレースホルダーへ渡しているか。文字列連結したSQLの安全性をQuery Builderが補ってくれるとは考えない。
  2. 列名・テーブル名・並び順の指定は、実在する固定候補から選んでいるか。値のバインドと同じ防御ではない。
  3. 取得をループで繰り返していないか。実際のSQL本数と処理時間を別に測る。
  4. 更新・削除・トランザクションには業務上の条件とDB固有の制約があるか。例外時のrollbackだけで業務の正しさまで保証しない。

隔離MySQLでは、Query Builderだけでも親2行の後に子を逐次取得すると3本、一括のwhereIn取得なら2本になることを確認しました。これはfixtureの本数で、任意の実装が常にこの値になる保証ではありません。

公式: Query Builderの値/列名とupsert、DB接続と暗黙COMMIT。関連はページングとSQL観測。以下の元コードは、注意書きと対象DBを確認しながら参照してください。

この記事の要点
  • Query Builder は DB::table("users")->where(...)->get() のように生 SQL をラップして安全に書ける
  • 通常の条件値はバインドされるが、列名や生SQL文字列は別に検証する。生SQLの値には whereRaw("col = ?", [$x])
  • join / groupBy / having / unionAll も全てメソッドチェーンで表現可能
  • トランザクションは DB::transaction(function () { ... })。例外で自動ロールバック
  • Eloquent との使い分け: 単純な集計・複雑な join は Query Builder、ドメインモデルを介す処理は Eloquent

Query Builder の基本

下のname順LIMIT10例は、同じ名前の行の順序まで確定しません。固定した結果境界が必要なら、id等の一意キーを決め手に追加します。first/valueは対象がない場合のnullも処理してください。

DB::table() から始めるメソッドチェーン形式で SQL を組み立てます:

use Illuminate\Support\Facades\DB;

// SELECT * FROM users WHERE active = 1
$users = DB::table('users')
    ->where('active', 1)
    ->get();

// SELECT id, name FROM users WHERE age >= 20 ORDER BY name LIMIT 10
$users = DB::table('users')
    ->select('id', 'name')
    ->where('age', '>=', 20)
    ->orderBy('name')
    ->limit(10)
    ->get();

// 単一行 / 単一カラム
$user  = DB::table('users')->where('id', 1)->first();
$name  = DB::table('users')->where('id', 1)->value('name');
$count = DB::table('users')->count();

WHERE 句のバリエーション

メソッド生成される SQL
where('age', '>', 20)age > 20
whereIn('id', [1,2,3])id IN (1,2,3)
whereBetween('age', [20,30])age BETWEEN 20 AND 30
whereNull('deleted_at')deleted_at IS NULL
whereExists(function ($q) {...})EXISTS (SELECT ...)
orWhere('email', $x)OR email = ?
whereRaw('YEAR(created_at) = ?', [2026])生 SQL(パラメータバインド)

JOIN / GROUP BY / HAVING

// 内部結合 + 集計
$stats = DB::table('users')
    ->join('orders', 'users.id', '=', 'orders.user_id')
    ->select('users.name', DB::raw('SUM(orders.amount) AS total'))
    ->groupBy('users.id', 'users.name')
    ->having('total', '>', 10000)
    ->orderByDesc('total')
    ->get();

// 左外部結合
$users = DB::table('users')
    ->leftJoin('profiles', 'users.id', '=', 'profiles.user_id')
    ->select('users.*', 'profiles.bio')
    ->get();

// サブクエリ join
$latest = DB::table('logins')
    ->select('user_id', DB::raw('MAX(login_at) AS last'))
    ->groupBy('user_id');

$users = DB::table('users')
    ->joinSub($latest, 'l', fn($j) => $j->on('users.id', '=', 'l.user_id'))
    ->select('users.name', 'l.last')
    ->get();

selectRaw / whereExists / Union

// selectRaw で集計関数や式を直接書く
$rows = DB::table('orders')
    ->selectRaw('YEAR(created_at) AS year, SUM(amount) AS total')
    ->groupBy(DB::raw('YEAR(created_at)'))
    ->get();

// EXISTS サブクエリ
$users = DB::table('users')
    ->whereExists(function ($q) {
        $q->select(DB::raw(1))
          ->from('orders')
          ->whereColumn('orders.user_id', 'users.id')
          ->where('orders.amount', '>', 10000);
    })
    ->get();

// UNION
$a = DB::table('users')->where('type', 'admin');
$b = DB::table('users')->where('type', 'editor');
$all = $a->unionAll($b)->get();

INSERT / UPDATE / DELETE

次は書込・物理削除を含む保守用の例です。確認目的で実データへ実行しないでください。 Query BuilderはEloquentのモデルイベント/キャスト/SoftDeletes等を自動適用するものではありません。必要な権限・論理削除・制約を別途設計します。MySQL/MariaDBのupsertは第2引数だけで重複列を決定せず、実テーブルのPRIMARY/UNIQUE制約を使います。

// 単一 INSERT
DB::table('users')->insert([
    'name'       => 'Alice',
    'email'      => 'alice@example.com',
    'created_at' => now(),
]);

// 一括 INSERT
DB::table('users')->insert([
    ['name' => 'Bob',     'email' => 'bob@example.com'],
    ['name' => 'Charlie', 'email' => 'charlie@example.com'],
]);

// INSERT して ID を取得
$id = DB::table('users')->insertGetId([
    'name'  => 'Dan',
    'email' => 'dan@example.com',
]);

// UPDATE
$affected = DB::table('users')
    ->where('id', 1)
    ->update(['name' => 'Alice Smith']);

// UPSERT(PHP 8 + Laravel 8+)
DB::table('users')->upsert(
    [['email' => 'a@x.com', 'name' => 'A'], ['email' => 'b@x.com', 'name' => 'B']],
    ['email'],       // 重複検出キー
    ['name']         // 更新対象カラム
);

// DELETE
DB::table('users')->where('active', 0)->delete();

生 SQL を実行: DB::select / DB::statement

次のTRUNCATE・DDL・連結SQLは、実行を勧めない旧資料です。 TRUNCATEはデータを失わせ、MySQLの一部DDLは暗黙COMMITを起こします。transactionで囲めばすべて戻せるとは判断しません。SELECTも権限・負荷・返却列の検討が必要です。

// SELECT 系
$rows = DB::select('SELECT * FROM users WHERE active = ?', [1]);

// 戻り値が不要なステートメント (CREATE / ALTER / TRUNCATE)
DB::statement('TRUNCATE TABLE logs');
DB::statement('CREATE INDEX idx_email ON users(email)');

// DDL
DB::unprepared('CREATE TABLE backup_users AS SELECT * FROM users');

// プレースホルダ必須(SQL インジェクション対策)
// ❌ 危険
DB::select("SELECT * FROM users WHERE email = '{$email}'");

// ✅ 安全
DB::select('SELECT * FROM users WHERE email = ?', [$email]);

トランザクション

次の口座更新は例外時のrollbackを示す断片で、送金の完成実装ではありません。残高・対象行の存在・同時更新・二重実行等を保証せず、例外を飲み込んで正常終了すれば自動rollbackにはなりません。再試行される処理へ外部決済・メール送信を無条件に含めないでください。

// クロージャ版(例外で自動ロールバック・推奨)
DB::transaction(function () {
    DB::table('accounts')->where('id', 1)->decrement('balance', 1000);
    DB::table('accounts')->where('id', 2)->increment('balance', 1000);
    DB::table('transfers')->insert([
        'from' => 1, 'to' => 2, 'amount' => 1000,
    ]);
});

// 手動制御版
DB::beginTransaction();
try {
    // ...
    DB::commit();
} catch (\Throwable $e) {
    DB::rollBack();
    throw $e;
}

// デッドロック時のリトライ回数(第 2 引数)
DB::transaction(function () { /* ... */ }, 3);

SQL インジェクション対策

通常のwhere等の条件値と、テーブル/列名・SQL式は別です。識別子は値としてバインドできないため、並び替え列等はアプリ側の固定許可リストから選びます。whereRawは第2引数へ渡す値をバインドできますが、生SQL文字列自体に入力を連結してよいわけではありません:

// ✅ 自動バインド
DB::table('users')->where('email', $email)->get();
// → SELECT * FROM users WHERE email = ?
//   バインド: [$email]

// ✅ whereRaw でもバインドできる
DB::table('users')->whereRaw('LOWER(email) = ?', [strtolower($email)])->get();

// ❌ 絶対やってはいけない(文字列連結)
DB::select("SELECT * FROM users WHERE email = '$email'");

Eloquent との使い分け

場面推奨理由
1 件取得 → 加工 → 保存Eloquentモデルのビジネスロジックを通す
複雑な集計レポートQuery BuilderSQL を直に書く方が見通し良い
大量バルク INSERTQuery Builder + insert()Eloquent はモデル生成のオーバヘッドあり
リレーション込みの取得Eloquent + with()N+1 回避が容易
外部 DB の参照Query Builderモデル不要

発行された SQL を確認

下のgetQueryLog/ddにはバインド値や個人情報が出る場合があります。公開画面や本番で試さず、専用の隔離データで確認してください。toSqlは実行結果ではなく1クエリの組立て結果です。

// toSql() で生 SQL(プレースホルダのまま)
$sql = DB::table('users')->where('id', 1)->toSql();
// → SELECT * FROM users WHERE id = ?

// バインドパラメータ
$bindings = DB::table('users')->where('id', 1)->getBindings();
// → [1]

// クエリログを取る(開発時のみ)
DB::enableQueryLog();
DB::table('users')->get();
dd(DB::getQueryLog());

FAQ

Q: get() と first() と value() の違い
A: get() はコレクション、first() は 1 行(オブジェクト or null)、value('col') は 1 カラムの値のみ。

Q: chunk / chunkById の違い
A: chunkのOFFSETとchunkByIdのキー条件を区別します。chunkByIdも処理中のキー/条件変更や同時更新を一律に安全にするものではありません。JOIN時の対象キー/別名やwhereの論理グループも確認し、固定データと変更を含む場合を別々に検証します。

Q: Query Builder で N+1 は起きる?
A: Query Builderでも、親一覧の後にループ内で子を1回ずつ取得すれば1+N本になります。Eloquentの自動取得に限った問題ではありません。whereInによる一括取得やJOIN/集計等を検討しますが、行数・重複・並び順・メモリと取得権限を前後で照合します。

子ページ
  1. SELECT
  2. INSERT
  3. UPDATE
  4. DELETE
  5. order by句のキャスト
  6. count / max / average (集計)
  7. 配列を条件にする方法
  8. where句の入れ子(ネスト)
同階層のページ
  1. インストールと設定
  2. クイックスタート & チュートリアル(初心者向け)
  3. クイックスタート & チュートリアル(中級者向け)
  4. ルーティング
  5. Bladeテンプレート(ビュー/レイアウト)
  6. コントローラー
  7. マイグレーションとテーブル定義
  8. データベースの設定
  9. Eloquentモデル (ORM)
  10. SQLとクエリビルダー
  11. バリデーション
  12. .envファイルの設定値へのアクセス
  13. 動作環境による分岐処理
  14. configフォルダ配下の設定値へのアクセス
  15. assetヘルパーを利用したpublicフォルダへのアクセス
  16. storageフォルダへのアクセス
  17. アプリケーション名の変更
  18. メンテナンス
  19. ログイン画面(認証システム)の作成
  20. ログインの必須化
  21. ログインユーザー情報の取得
  22. ルートの認証化
  23. 本番サーバーへのデプロイ方法
  24. 多言語化
  25. csrf_field
  26. ファイルのダウンロード
  27. CSVのアップロードおよび読み込み(maatwebsite/excel)
  28. ページタイトルの設定
  29. コマンド一覧
  30. エラー一覧
  31. SQLの実行ログ出力方法
  32. キャッシュのクリア
  33. Selectの結果の最初もしくは最後に任意の値を追加する方法
  34. ajaxでPOST通信する際の注意点
  35. ソーシャルログインの実装
  36. セッション情報の確認
  37. ログイン、ユーザー登録、パスワードリセット後のリダイレクト先の変更方法
  38. redirectやreturn viewにメッセージを付与する方法
  39. クッキー(cookie)の設定と取得
  40. クラスの再読み込み
  41. csrfの有効時間を変更する方法
  42. ViewComposerを用いてviewに共通の値を付与する方法
  43. View::shareを用いて共通の値を各ビューに渡す方法
  44. ミドルウェアを用いた処理の共通化
  45. Middleware内でAuth::check()などを使用する方法
  46. Controller以外でリダイレクトする方法
  47. セッションの値の取得/保存/更新/削除
  48. $requestの値を変更する方法
  49. 常時SSL化
  50. ページング(ページネーション)をする方法
  51. vue.jsとの連携
  52. Vue.jsと連携するSPA実行環境構築
  53. .envの値をvue.jsで参照する方法
  54. vue.jsを本番環境にリリースする方法
  55. could not find driver(Windows, MySQL編)

最近更新/作成されたページ