iT邦幫忙

2026 iThome 鐵人賽

DAY 5
0
Software Development

一套真實運作中的 Laravel 系統,拆解它的原生機制系列 第 5

Day 05:查詢邏輯怎麼寫成可組合的 Query Scope Class

  • 分享至 

  • xImage
  •  

前言:where 條件寫在 Controller 裡,跟寫在別的地方,差在哪?

「查詢條件不就是一串 where 嗎?寫在 Controller 裡,寫在 Model 裡,結果不都一樣?」

結果確實一樣,但**「同一段查詢邏輯要不要在第二個地方重複用到」才是關鍵**。今天要看這個系統怎麼把「已發佈」「排序規則」這類會被多處引用的查詢條件,收斂成獨立的、可以互相組合的 class。

今日目標

  • 理解 Laravel 的 Query Scope 除了傳統的 scopeXxx() 方法,還有 invokable class 這種寫法
  • 看一個真實案例:「已發佈」判斷邏輯怎麼被抽成一個獨立 class
  • 理解疊加多個 scope 是怎麼運作的
  • 建立一個判斷基準:什麼樣的查詢條件值得抽成獨立 scope

本文主體

兩種寫 Query Scope 的方式

Laravel 官方文件教的 Query Scope 寫法,通常是在 Model 裡定義一個 scopeXxx() 方法:

// 傳統寫法
public function scopePublished(Builder $query): void
{
    $query->where('published_at', '<=', now());
}

// 呼叫時
News::published()->get();

這個系統選擇了另一種寫法——把每個查詢條件寫成一個獨立的 invokable class(實作 __invoke() 方法),透過 tap() 套用到查詢上:

// app/Models/Scopes/News/Published.php
class Published
{
    private Carbon $now;

    public function __construct(?Carbon $now = null)
    {
        $this->now = $now ?? now();
    }

    public function __invoke(Builder $query): void
    {
        $query
            ->where('published_at', '<=', $this->now)
            ->where(function (Builder $query) {
                $query
                    ->where('published_until', '>=', $this->now)
                    ->orWhereNull('published_until');
            })
            ->where('published', true);
    }
}

// 呼叫時
News::query()->tap(new Published)->get();

為什麼特地寫成獨立 class

這個「已發佈」判斷邏輯,其實同時處理了三個條件:開始發佈時間到了、結束發佈時間還沒到(或沒有設結束時間)、發佈狀態旗標開著——不是單純一個布林欄位就能表達的邏輯。把它寫成獨立 class,換來幾個好處:

  • 可以帶建構參數Published 的建構子接受一個 ?Carbon $now,測試時可以傳入固定時間,不用依賴系統當下時間,這是 scopeXxx() 方法比較難做到的。
  • 可以獨立測試:這個 class 不依賴任何 Model,單元測試時可以直接組一個查詢建構器丟進去驗證,不用透過完整的 Eloquent Model。
  • 命名空間天然分類Models/Scopes/News/Published.phpModels/Scopes/Page/Published.php 各自獨立,即使兩個 Model 的「已發佈」定義不一樣,也不會互相干擾或混淆。

一個 Model 疊多個 scope

真正的威力在於多個 scope 可以自由組合,不需要在 Model 裡預先定義好所有排列組合:

public function publishedAncestors(): AncestorsRelation
{
    return $this->ancestors()
        ->tap(new Alias)
        ->tap(new Published)
        ->defaultOrder();
}

這裡同時疊了 Alias(處理別名頁面轉換)跟 Published(只要已發佈的)兩個 scope,兩者互不依賴、可以獨立測試,也可以在別的查詢裡只用其中一個。另一個常見的組合是排序:

// app/Models/Scopes/News/DefaultSort.php
class DefaultSort
{
    public function __invoke(Builder $query): void
    {
        $query->orderByDesc('on_top')
            ->orderByDesc('sort_order')
            ->orderByDesc('published_at')
            ->orderByDesc('id');
    }
}

「置頂優先、自訂排序優先、發佈時間新的優先」這組排序規則,只要 ->tap(new DefaultSort) 就能套用,不用在每個查詢新聞列表的地方重複寫一次四層 orderBy

❌ 每個地方各自寫一次查詢條件

// Controller A
News::where('published_at', '<=', now())
    ->where('published', true)
    ->orderByDesc('on_top')
    ->orderByDesc('published_at')
    ->get();

// Controller B(同樣的條件,複製貼上一次)
News::where('published_at', '<=', now())
    ->where('published', true)
    ->orderByDesc('on_top')
    ->orderByDesc('published_at')
    ->get();

兩處查詢條件要是有一天邏輯要改(例如加入結束時間判斷),必須記得兩處都要改。

✅ 查詢條件收斂成可組合、可重複使用的 scope

News::query()->tap(new Published)->tap(new DefaultSort)->get();

今日思考題

你的專案裡有沒有一段查詢條件(不只是單一 where,而是一組組合條件),在超過一個地方各自寫了一次?如果現在要幫它加一個新條件,你有把握每個地方都會記得改嗎?

今日重點回顧

  • Query Scope 除了官方文件常見的 scopeXxx() 方法,也可以寫成 invokable class,透過 tap() 套用
  • 真實案例:「已發佈」判斷邏輯(開始時間、結束時間、發佈旗標三個條件)被抽成獨立的 Published class
  • 獨立 class 的好處:可以帶建構參數、可以脫離 Model 獨立測試、命名空間天然分類
  • 多個 scope 可以自由疊加組合,不用預先定義好每一種排列組合

明日預告

明天要細看 Page 身兼多職的其中一個關鍵技巧——用一個 backed enum,把資料庫欄位存的字串值轉成型別安全的查找表。


上一篇
Day 04:一個 Model 身兼多職——Page 怎麼用 Nested Set 疊動態型別撐起整個頁面樹
下一篇
Day 06:Backed Enum 玩法——用類別常數當 enum 值做型別查找表
系列文
一套真實運作中的 Laravel 系統,拆解它的原生機制14
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言