iT邦幫忙

2026 iThome 鐵人賽

DAY 11
0
Modern Web

我推的Laravel S2!系列 第 11

我推的Laravel S2|Day 10:查詢建構器精選:條件、排序、Join、分頁

  • 分享至 

  • xImage
  •  

一句話破題

「怎麼把資料查出來」大概是你寫 Laravel 時最高頻的動作,而查詢建構器提供的方法多到沒有人會全部記得——今天的目標不是背完,是建立一份「遇到這種需求,該往哪個方法找」的心智索引。這塊語法從 Laravel 10 到 13 沒有任何破壞性變動,是可以放心沿用舊知識的一天。

前情提要

Day 9 我們建好了 PostUser 的一對多關聯。今天用這組資料,把 Eloquent 查詢建構器裡最常用的方法整理成一份速查表——查詢建構器的方法有近 50 個,這裡跟 Day 8 一樣採精選策展的方式,挑出實務上真正高頻使用的核心方法。

取得結果

Post::all();                    // 取得整張表的所有資料(小心表很大時效能問題)
Post::get();                    // 搭配 where 等條件使用,取得符合條件的所有結果
Post::first();                  // 取第一筆
Post::firstOrFail();            // 取不到就拋出 ModelNotFoundException(配合路由自動回 404)
Post::find(1);                  // 依主鍵查詢
Post::findOrFail(1);            // 查不到就拋例外
Post::count();                  // 計算符合條件的筆數
Post::exists();                 // 判斷符合條件的資料是否存在,比 count() > 0 更有效率
Post::query()->toSql();         // 印出組出來的 SQL 字串,除錯神器
Post::where('user_id', 1)->value('title');   // 只取單一欄位的值(不是整個 Model),比 first()->title 少一次物件建構
Post::pluck('title', 'id');     // 取出一組「id => title」的陣列,下拉選單資料源的常見寫法

findOrFail()/firstOrFail() 在 Controller 裡特別好用,因為 Laravel 會自動把 ModelNotFoundException 轉換成 404 回應,不需要自己寫 if (!$post) abort(404)

條件

// 基本條件(第二個參數省略時預設是 =)
Post::where('user_id', $userId)->get();
Post::where('title', 'like', '%Laravel%')->get();

// 多個條件(AND)
Post::where('user_id', $userId)->where('published_at', '<=', now())->get();

// OR 條件
Post::where('title', 'like', '%Laravel%')->orWhere('body', 'like', '%Laravel%')->get();

// whereIn / whereNotIn
Post::whereIn('user_id', [1, 2, 3])->get();

// whereNull / whereNotNull:判斷是否為 null,草稿 vs 已發布的典型應用
Post::whereNull('published_at')->get();     // 草稿
Post::whereNotNull('published_at')->get();  // 已發布

// whereBetween:時間區間查詢
Post::whereBetween('published_at', [$startDate, $endDate])->get();

條件式查詢:when()

實務上很常遇到「這個條件要不要加,取決於使用者有沒有傳這個參數」的情境,例如文章列表頁的關鍵字搜尋是選填的。與其寫一堆 if 判斷,when() 讓你把條件邏輯直接寫進查詢鏈:

Post::query()
    ->when($request->filled('keyword'), function ($query) use ($request) {
        $query->where('title', 'like', "%{$request->keyword}%");
    })
    ->when($request->filled('author_id'), fn ($query) => $query->where('user_id', $request->author_id))
    ->latest()
    ->paginate(10);

when() 的第一個參數是布林值(條件成立才會執行閉包),第二個參數是條件成立時要執行的閉包,還可以帶第三個參數作為條件不成立時的替代邏輯。這個寫法在 Day 23 會實際用在 PostRepository::search() 的實作裡,是處理「多個選填篩選條件組合查詢」最乾淨的方式,比一長串巢狀 if 好讀也好維護。

關聯是否存在的查詢:whereHas

如果你想查「有已發布文章的作者」,不能單純對 Userwhere,因為「有沒有已發布文章」是關聯資料表的條件,這時候用 whereHas()

// 找出至少有一篇已發布文章的使用者
User::whereHas('posts', function ($query) {
    $query->whereNotNull('published_at');
})->get();

// 找出完全沒有發過文的使用者
User::doesntHave('posts')->get();

// whereHas 也支援直接帶數量條件:至少要有 3 篇已發布文章
User::whereHas('posts', fn ($q) => $q->whereNotNull('published_at'), '>=', 3)->get();

whereHas() 底層用的是子查詢(WHERE EXISTS (...)),跟直接用 join()groupBy() 統計是不同的實作方式,但語意上更直接對應「這個關聯要滿足什麼條件」,可讀性通常更好。

排序和群組

Post::orderBy('published_at', 'desc')->get();
Post::latest('published_at')->get();        // orderBy(desc) 的語意化簡寫
Post::oldest()->get();                       // 依 created_at 由舊到新
Post::inRandomOrder()->first();              // 隨機取一筆,適合「隨機推薦文章」這類功能

Post::groupBy('user_id')
    ->selectRaw('user_id, count(*) as post_count')
    ->get();                                 // 統計每位作者的文章數

Post::groupBy('user_id')
    ->havingRaw('count(*) > ?', [5])
    ->get();                                 // 群組後再篩選(發文超過 5 篇的作者)

Join

Eloquent 關聯(Day 9 的 author()/posts())已經涵蓋多數需求,但需要跨表統計、或關聯條件比較複雜時,直接用 Join 會更直觀:

Post::join('users', 'posts.user_id', '=', 'users.id')
    ->select('posts.*', 'users.name as author_name')
    ->get();

Post::leftJoin('users', 'posts.user_id', '=', 'users.id')
    ->get();   // 就算 user_id 對不到(理論上不該發生,因為有外鍵約束)也會保留該筆 post

https://ithelp.ithome.com.tw/upload/images/20260812/20163286IhaFc6kzoT.png

一般建議:能用 Eloquent 關聯(with(),Day 11 會提到 Eager Loading 避免 N+1)解決的情境優先用關聯,Join 保留給真正需要「一次查詢橫跨多表做統計」的場景。

分頁

$posts = Post::latest()->paginate(10);
@foreach ($posts as $post)
    <h2>{{ $post->title }}</h2>
@endforeach

{{ $posts->links() }}

paginate() 除了回傳資料,還自動附帶總筆數、目前頁碼等資訊,links() 在 Blade 裡直接印出分頁導覽列(樣式會自動配合你專案使用的 CSS 框架)。如果不需要頁碼導覽、只需要「上一頁/下一頁」的簡易分頁(例如無限捲動),改用 simplePaginate() 效能更好,因為它不需要額外查詢總筆數。

還有一種更適合「無限捲動」情境的分頁方式——cursorPaginate(),用「游標」取代「頁碼」定位下一批資料,即使資料在瀏覽過程中被新增/刪除,也不會像頁碼分頁那樣出現「同一筆資料被重複顯示」或「漏掉一筆」的情況,且在大資料量時效能比 paginate() 更穩定(不需要用 OFFSET 跳過大量前面的資料列):

$posts = Post::latest()->cursorPaginate(10);

處理大量資料:chunk() 與 lazy()

如果你要對整張資料表做批次處理(例如 Day 25 會示範的「清除過期草稿」排程任務),直接 Post::all() 會把整張表一次載進記憶體,資料量一大就可能耗盡記憶體。這種情境要用分批處理:

// chunk:每次處理 100 筆,處理完釋放記憶體再查下一批
Post::whereNull('published_at')->chunk(100, function ($posts) {
    foreach ($posts as $post) {
        // 處理邏輯
    }
});

// chunkById:跟 chunk 類似,但用主鍵定位下一批(資料在處理過程中被刪除也不會漏掉/重複,比 chunk 更穩定)
Post::whereNull('published_at')->chunkById(100, function ($posts) {
    foreach ($posts as $post) {
        $post->delete();   // chunk() 在這種「處理過程中會刪除資料」的情境容易漏掉部分紀錄,chunkById() 才是正確選擇
    }
});

// lazy:回傳一個懶惰載入的集合,用 foreach 直接迭代,底層自動分批查詢,語法比 chunk 更直觀
foreach (Post::whereNull('published_at')->lazy() as $post) {
    // 處理邏輯
}

chunk()chunkById() 的差異值得特別注意:如果你的批次處理邏輯會刪除更新排序欄位,用 chunk() 可能因為底層用 OFFSET 定位下一批,導致資料被刪除後後續批次的位移跟著跑掉,出現漏掉部分紀錄的情況。凡是處理過程中會影響到查詢條件本身的批次操作,優先用 chunkById()

查詢日誌:確認你的查詢真正執行了什麼

開發階段想知道 Eloquent 底層實際組出了什麼 SQL,除了前面提過的 toSql(),還可以監聽所有查詢事件:

// app/Providers/AppServiceProvider.php(僅建議在本機環境啟用)
use Illuminate\Support\Facades\DB;

public function boot(): void
{
    if ($this->app->environment('local')) {
        DB::listen(function ($query) {
            Log::debug($query->sql, $query->bindings);
        });
    }
}

這在排查「為什麼這個查詢這麼慢」「這個查詢到底綁定了什麼參數值」時非常直接,比自己在程式碼裡到處插 dd() 有效率。Day 28 會提到的 Telescope 套件,其中一個核心功能就是把這類查詢日誌整理成一個好用的網頁儀表板,等於是把這裡手動監聽的邏輯包裝成更完整的開發工具。

速查表

分類 方法 用途
取得結果 get() / all() / first() / find() 依情境取得單筆或多筆
取得結果 findOrFail() / firstOrFail() 找不到自動拋例外、配合路由回 404
取得結果 value() / pluck() 只取單一欄位/欄位組,避免載入整個 Model
條件 where() / orWhere() / when() 基本條件組合,when() 適合選填條件
條件 whereIn() / whereNull() / whereBetween() 常見的特殊條件情境
條件 whereHas() / doesntHave() 依關聯資料是否存在/符合條件篩選
排序 orderBy() / latest() / oldest() / inRandomOrder() 排序,latest/oldest 是語意化簡寫
群組 groupBy() / having() 統計彙總情境
Join join() / leftJoin() 跨表查詢,關聯無法滿足時使用
分頁 paginate() / simplePaginate() / cursorPaginate() 列表分頁,依情境選擇不同的效能取捨
批次處理 chunk() / chunkById() / lazy() 處理大量資料時避免記憶體耗盡

常見錯誤與踩雷點

  • 在迴圈裡對關聯做查詢,觸發 N+1 問題foreach ($posts as $post) { echo $post->author->name; } 如果沒有先 with('author') 預先載入,每次迴圈都會多發一次 SQL 查詢。Day 11 會示範 Eager Loading 怎麼解決這個問題。
  • whereIn() 傳入空陣列Post::whereIn('id', [])->get() 不會報錯,但會回傳空結果集,這是正確行為但常常不是預期行為,記得在呼叫前檢查陣列是否為空。
  • get()->count() 而不是直接 count()get() 會把所有符合條件的資料實際查出來、載入成 Model 物件集合再算數量,count() 是直接讓資料庫算 COUNT(*),資料量大時效能差異明顯。
  • 批次處理刪除資料時用 chunk() 而不是 chunkById():前面提過的陷阱,chunk() 在處理過程中刪除資料會導致後續批次漏掉紀錄,這是一個不會報錯、但結果悄悄不完整的隱性 bug,很難在開發階段用少量測試資料發現。
  • paginate() 用在資料量非常大的表,卻沒意識到深頁碼的效能問題paginate() 底層用 OFFSET 定位,頁碼越後面(例如第 1000 頁),資料庫要先掃過並跳過前面 9990 筆資料,效能會隨頁碼增加而變差。真的需要支援深度分頁瀏覽的大型列表,cursorPaginate() 是更穩定的選擇。

小結

今天的查詢建構器速查表涵蓋了條件、排序、Join、分頁四大類最常用的方法,並補上了 when() 條件式查詢、whereHas() 關聯條件查詢、chunk()/lazy() 大量資料批次處理這幾個實務上經常用到、但很容易被入門教材忽略的進階主題。這些寫法在 Laravel 10 到 13 之間沒有語法上的破壞性變動。真正該養成的習慣不是背熟每個方法,而是遇到需求時知道「這種情境查詢建構器有現成方法可以用」,比自己手刻 SQL 字串安全也好維護。

熟能生巧 — 中國諺語

明日預告

Day 11 進入 Eloquent 進階:新增/更新/刪除、Transaction,還有這系列第一個重要的版本更新——Laravel 11 新增的 casts() 屬性轉型方法,以及 Laravel 12 起 HasUuids 預設改為 UUIDv7。


上一篇
我推的Laravel S2|Day 09:Eloquent Model 入門:連線、Migration、關聯
下一篇
我推的Laravel S2|Day 11:Eloquent 進階:新增/更新/刪除、Transaction、casts()、UUIDv7
系列文
我推的Laravel S2!12
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言