「一個 Model 對應一張資料表,這不是很單純的事嗎?」
大部分教學範例裡的 Model 確實單純——一個欄位對應一個屬性,一個關聯對應一個方法。但真實系統裡,有些 Model 天生就要身兼多職,因為它要處理的資料本身就不單純。今天要看的 Page Model 就是一個好例子:它同時是一棵樹(頁面可以有子頁面)、也是一個依欄位值決定行為的多型物件(同一個 Model,依 template 欄位的不同,會表現出完全不同的行為)。
NodeTrait 實作階層樹狀結構(nested set)url/target 兩個 accessor 怎麼依型別給出完全不同的答案resolveRouteBinding() 怎麼讓路由直接用 slug 查到頁面這個系統的「單頁內容」用階層樹狀結構管理——一個頁面底下可以掛子頁面,子頁面底下還可以再掛孫頁面。實作這種樹狀結構,Page Model 用了 kalnoy/nestedset 套件提供的 NodeTrait:
class Page extends Model implements AuditableContract
{
use Auditable;
use HasFactory;
use NodeTrait;
// ...
}
NodeTrait 底層用的是 nested set 演算法(每個節點記錄左右邊界值),換來的好處是查詢一個節點的所有祖先或所有子孫,不需要遞迴查詢,一次 SQL 就能拿到整條路徑。Page 進一步疊了兩個組合查詢:
public function publishedAncestors(): AncestorsRelation
{
return $this->ancestors()
->tap(new Alias)
->tap(new Published)
->defaultOrder();
}
public function publishedChildren(): HasMany
{
return $this->children()
->tap(new Alias)
->tap(new Published)
->defaultOrder();
}
ancestors()/children() 是 NodeTrait 提供的基礎關聯方法,publishedAncestors()/publishedChildren() 再疊上「只要已發佈的」「處理別名頁面」這兩層業務邏輯的 scope(明天會細講 scope 這個設計)。這代表 nested set 提供的是「怎麼查出樹狀結構」這個底層能力,實際業務要的「只顯示已發佈的祖先」則是疊加在它之上,兩層責任分得很清楚。
Page 有一個 template 欄位,存的其實是後台一個類別的完整類別名稱字串。透過一個 backed enum(明天後天會展開講這個技巧),這個欄位被轉換成一個型別:
protected function type(): Attribute
{
return Attribute::get(fn () => PageType::tryFrom($this->template));
}
這個 type 不是資料庫的欄位,是從 template 動態算出來的衍生屬性。有了這個型別,Page 就能依型別分支出完全不同的行為——同一個 url() accessor,依 type 的不同,給出完全不一樣的答案:
protected function url(): Attribute
{
return Attribute::get(function () {
if ($this->type === PageType::ALIAS) {
return $this->alias?->url;
}
if ($this->type === PageType::LINK) {
return $this->content['url'];
}
if (! $this->slug) {
return '#';
}
$domain = $this->slug !== 'home' && $this->type === PageType::NEWS ? News::domain() : self::domain();
return route('page', ['domain' => $domain, $this->slug === 'home' ? '/' : $this->slug]);
});
}
一個「別名」頁面的網址,是它指向的目標頁面的網址;一個「連結」型頁面的網址,是存在 content JSON 裡的一個外部連結;一個一般頁面,才走正常的路由組出網址。target() accessor 也是同樣的分支邏輯,只有「連結」型頁面才可能需要在新分頁開啟。
if ($page->template === 'App\\Filament\\PageTemplates\\LinkTemplate') {
// ...
}
裸字串比對容易打錯字、換類別名稱時不會被 IDE 抓到,也完全看不出這個欄位總共有哪些合法值。
$this->type === PageType::LINK
轉成 enum 之後,合法值一目了然,打錯字會在編譯期直接報錯,IDE 也能自動完成——這個轉換技巧本身,明天會拆開來細講。
Page 還覆寫了 resolveRouteBinding(),讓路由參數可以直接用 slug 查到對應的頁面,而不是預設的用主鍵 ID:
public function resolveRouteBinding($value, $field = null): Model|bool
{
return $value
? $this->withDepth()->where('slug', $value)->firstOrFail()
: false;
}
這是 Laravel 路由模型綁定(route model binding)的擴充點——只要覆寫這個方法,路由參數就能自動用你想要的欄位去查資料,不用在每個 Controller 裡手動 Page::where('slug', $slug)->firstOrFail()。
Page 的內容存成 JSON block 陣列,renderPage() 依每個 block 的型別(paragraph-rich-editor/paragraph-markdown/image)分別處理成 HTML:
public function renderPage(): string
{
$converter = app(ConverterInterface::class);
return ! $this->content ? '' : collect($this->content['content'])
->map(function (array $block) use ($converter) {
if ($block['type'] === 'paragraph-rich-editor') {
return (string) $block['data']['content'];
}
if ($block['type'] === 'paragraph-markdown') {
return (string) $converter->convert($block['data']['content'] ?? '');
}
if ($block['type'] === 'image') {
return $this->renderBlockImage($block['data']);
}
return Arr::get($block, 'data.content', '');
})->implode("\n");
}
一個頁面其實是由多個「區塊」組成的,各自可能是富文字、Markdown,或圖片,renderPage() 負責把這些異質的區塊組成最終要輸出的 HTML。
你的專案裡有沒有一個 Model,同時扛了「資料結構」跟「行為分派」兩種責任?這種設計方便還是複雜,取決於什麼條件?
Page 用 kalnoy/nestedset 的 NodeTrait 實作階層樹狀結構,一次查詢就能拿到整條祖先/子孫路徑ancestors/children)之上,疊加了業務層的 scope(只顯示已發佈的)template 欄位透過 backed enum 動態算出 type,url/target accessor 依 type 分支出完全不同的行為resolveRouteBinding() 讓路由可以直接用 slug 查到頁面,不用手動查詢renderPage() 依區塊型別分別渲染明天要看查詢邏輯怎麼從裸寫的 where 條件,收斂成一個個可以組合、可以重複使用的 Query Scope class。