後台管理與進階查詢:用 Filament 打造管理介面
本篇是「PHP/Laravel 完全指南」系列的第 12 / 15 篇。你可以從系列總覽開始閱讀,也可以直接接著看本文。
每一個有使用者的平台,遲早都需要一個管理後台。你可以從零開始刻——自己寫列表頁、編輯頁、篩選器、圖表——但說實話,那些重複的 CRUD 介面真的不值得你花好幾天去做。Filament 5 是 Laravel 生態系中最成熟的後台框架,完全開源免費,而且跟 Laravel 的整合度高到像是原生功能一樣。
但後台管理不只是有畫面就好。當你的團購平台上有幾千筆開團紀錄、幾萬筆訂單,你會發現頁面開始變慢,資料庫查詢數量爆增。這時候你需要的不是加更多伺服器,而是搞懂 Eloquent 的進階查詢技巧——Eager Loading 解決 N+1 問題、Query Scopes 讓查詢條件可重用、Subqueries 處理複雜的報表需求。
這一章我們分兩條線走:前半段用 Filament 5 幫揪好買建立開團主儀表板和站台管理員後台,後半段深入 Eloquent 進階查詢,搭配 Laravel Debugbar 實際觀察效能差異。你會發現,寫出「能跑」的程式碼和寫出「跑得快」的程式碼之間,差距往往就在這些細節裡。
為什麼選 Filament:開源、免費、功能完整
Laravel 生態系有好幾套後台管理方案,最常被拿出來比較的是 Filament、Nova、Backpack。先看一張比較表:
| 特性 | Filament 5 | Nova 5 | Backpack v7 |
|---|---|---|---|
| 授權 | MIT(完全免費) | 付費(Solo $99/site、Pro $199/site) | 免費核心 + 付費 PRO |
| 技術棧 | Livewire + Tailwind | Vue 3 + Inertia | Blade + Bootstrap |
| Panel Builder | 內建 | 內建 | 內建 |
| Form Builder | 內建 | 內建 | 內建 |
| Table Builder | 內建 | 內建 | 內建 |
| Dashboard Widgets | 內建 | 內建 | 付費 PRO |
| Multi-tenancy | 內建支援 | 需套件 | 需套件 |
| Plugin 生態 | 活躍(200+) | 豐富 | 中等 |
| 學習曲線 | 中等 | 低 | 低 |
| 自訂性 | 極高 | 高 | 中 |
Nova 是 Laravel 官方出品,品質穩定,但它不是開源的——Nova 5 授權分兩階:Solo(年營收 < $20k 個人開發者)$99/site、Pro $199/site,一次性永久授權含一年更新,而且你看不到原始碼,沒辦法深度客製。Backpack v7 以 Blade 元件(Data Components)為核心,技術棧採 Blade + Bootstrap,穩定度高且持續維護。
Filament 5 的優勢很明確:
- 完全開源、MIT 授權——你可以用在商業專案、修改原始碼、不需要付費
- 跟我們的技術棧一致——Livewire + Tailwind CSS,跟揪好買的前端技術完全相同
- 功能不打折——Dashboard widgets、Chart、多租戶、通知系統⋯⋯全部免費
- 社群活躍——GitHub 上超過 30,000 stars,plugin 生態豐富
如果你從 JavaScript 世界來,Filament 的角色類似於 React Admin 或 Refine——但它不需要你寫前端程式碼,全部用 PHP 搞定。
但 Filament 不是無條件的最佳解
上面那張表我給 Filament 打的分數偏高,所以這裡得誠實補一段,不然你會以為它沒有缺點。它的優勢和短板其實是同一件事的兩面:Livewire 的 server-driven 架構。
- 每次互動都要回伺服器一趟。 你點篩選器、改一個欄位、翻一頁,背後都是一次 AJAX round-trip 回 PHP 重算畫面。後台在公司內網、操作的人坐在你旁邊,這完全無感;但如果管理員在咖啡廳用 4G、或者你做的是欄位超多的大表單即時連動,那個延遲是會被抱怨的。Nova 之所以用 Vue + Inertia、有人寧可自刻 React/Vue SPA admin,理由就在這——純前端互動不用每動一下都等網路。
- 「全部用 PHP 搞定」有個但書。 內建 component 覆蓋得到的範圍,確實爽到不行;可是一旦你要做的東西超出內建(自訂互動元件、塞一段客製 JS 行為),你還是得回頭懂 Livewire 和 Alpine.js 的心智模型,不是純後端就能搞定。學習曲線那欄我標「中等」,代價就藏在這裡。
- Panel 大起來要顧效能。 一個頁面塞太多 Livewire widget、table 又開一堆即時運算的欄位,元件數量上去之後 server 端負擔和回應時間都會浮現,這時候該做的是精簡 widget、把重查詢丟進快取或非同步,而不是無腦加機器。
所以選型不是「Filament 贏者全拿」。後台要極致即時互動或得離線跑 → SPA admin 可能更合適;團隊已經買單 Nova 官方支援、不想碰 Livewire → Nova 很穩;需求簡單到連框架都嫌重 → 自刻幾頁 CRUD 反而最快。我這章選 Filament,是因為它對「Laravel 團隊、後台、開源免費」這個組合的命中率最高,不是因為它打趴所有對手。
Filament 5 安裝與 Resource 建立
安裝 Filament
composer require filament/filament
安裝 Panel Builder 並設定管理面板:
php artisan filament:install --panels
這條指令會做幾件事:
- 建立
app/Providers/Filament/AdminPanelProvider.php——管理面板的核心設定 - 發布相關的 assets 和 config
- 設定
/admin路由
建立管理員帳號
php artisan make:filament-user
終端會問你姓名、Email、密碼。填完之後,打開瀏覽器連到 http://localhost:8000/admin,登入就能看到空白的管理面板。
注意: Filament 預設使用
userstable 的帳號,但只有被標記為 Filament 使用者的人才能登入後台。你可以在AdminPanelProvider裡自訂登入邏輯。
建立第一個 Resource
Resource 是 Filament 的核心概念——每個 Resource 對應一個 Eloquent Model,自動生成 CRUD 介面:
php artisan make:filament-resource GroupBuy
這會在 app/Filament/Resources/ 下建立一整組檔案:
app/Filament/Resources/
├── GroupBuyResource.php # 主要設定檔
└── GroupBuyResource/
└── Pages/
├── CreateGroupBuy.php # 建立頁面
├── EditGroupBuy.php # 編輯頁面
└── ListGroupBuys.php # 列表頁面
一條 Artisan 指令,三個完整的 CRUD 頁面就出來了。接下來只需要在 GroupBuyResource.php 裡定義表單欄位和列表欄位。
CRUD Panel:自動化的管理介面
GroupBuyResource.php 的核心結構由三個方法組成:form()、table()、getRelations()。
Form Schema:定義表單
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\GroupBuyResource\Pages;
use App\Models\GroupBuy;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
class GroupBuyResource extends Resource
{
protected static ?string $model = GroupBuy::class;
protected static ?string $navigationIcon = 'heroicon-o-shopping-bag';
protected static ?string $navigationLabel = '團購管理';
protected static ?string $modelLabel = '團購';
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\Section::make('基本資訊')
->schema([
Forms\Components\TextInput::make('title')
->label('團購標題')
->required()
->maxLength(255),
Forms\Components\Textarea::make('description')
->label('說明')
->rows(4)
->columnSpanFull(),
Forms\Components\Select::make('organizer_id')
->label('開團主')
->relationship('organizer', 'name')
->searchable()
->preload()
->required(),
Forms\Components\Select::make('status')
->label('狀態')
->options([
'draft' => '草稿',
'open' => '開團中',
'closed' => '已截止',
'confirmed' => '已成團',
'cancelled' => '已取消',
])
->default('draft')
->required(),
])->columns(2),
Forms\Components\Section::make('時間與門檻')
->schema([
Forms\Components\DateTimePicker::make('starts_at')
->label('開始時間')
->required(),
Forms\Components\DateTimePicker::make('ends_at')
->label('截止時間')
->required()
->after('starts_at'),
Forms\Components\TextInput::make('min_participants')
->label('最低成團人數')
->numeric()
->minValue(1)
->required(),
Forms\Components\TextInput::make('max_participants')
->label('人數上限')
->numeric()
->nullable(),
])->columns(2),
Forms\Components\Section::make('圖片')
->schema([
Forms\Components\FileUpload::make('image')
->label('封面圖')
->image()
->directory('group-buys')
->maxSize(2048),
]),
]);
}
幾個值得注意的設計:
Section把表單分成邏輯區塊,管理員不用面對一整片欄位Select::make()->relationship()自動從關聯 Model 撈資料,還支援搜尋DateTimePicker::make()->after('starts_at')內建驗證,截止時間必須晚於開始時間FileUpload直接處理檔案上傳,不用自己寫 storage 邏輯
Table Schema:定義列表
接續同一個 GroupBuyResource class:
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('title')
->label('標題')
->searchable()
->sortable()
->limit(30),
Tables\Columns\TextColumn::make('organizer.name')
->label('開團主')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('status')
->label('狀態')
->badge()
->color(fn (string $state): string => match ($state) {
'draft' => 'gray',
'open' => 'success',
'closed' => 'warning',
'confirmed' => 'primary',
'cancelled' => 'danger',
default => 'gray',
})
->formatStateUsing(fn (string $state): string => match ($state) {
'draft' => '草稿',
'open' => '開團中',
'closed' => '已截止',
'confirmed' => '已成團',
'cancelled' => '已取消',
default => $state,
}),
Tables\Columns\TextColumn::make('participants_count')
->label('參加人數')
->counts('participants')
->sortable(),
Tables\Columns\TextColumn::make('ends_at')
->label('截止時間')
->dateTime('Y-m-d H:i')
->sortable(),
Tables\Columns\TextColumn::make('created_at')
->label('建立時間')
->dateTime('Y-m-d')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\Filters\SelectFilter::make('status')
->label('狀態')
->options([
'draft' => '草稿',
'open' => '開團中',
'closed' => '已截止',
'confirmed' => '已成團',
'cancelled' => '已取消',
]),
Tables\Filters\Filter::make('active')
->label('進行中')
->query(fn ($query) => $query
->where('status', 'open')
->where('ends_at', '>', now())
),
])
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\ViewAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
])
->defaultSort('created_at', 'desc');
}
這段程式碼產出的效果:一個有搜尋、排序、篩選、分頁的完整列表頁面。狀態欄位用 Badge 顯示彩色標籤,參加人數用 counts() 自動計算。如果用手寫的方式做這些功能,至少要花兩三天。
關聯管理
接續同一個 GroupBuyResource class:
public static function getRelations(): array
{
return [
RelationManagers\OrdersRelationManager::class,
RelationManagers\ParticipantsRelationManager::class,
];
}
Relation Manager 讓你在團購的編輯頁面直接管理訂單和參加者——不用跳到其他頁面。建立方式:
php artisan make:filament-relation-manager GroupBuyResource orders amount
Dashboard Widgets:資料視覺化
空白的 Dashboard 不太有用。Filament 提供了幾種內建 Widget,讓你快速建立數據儀表板。
StatsOverview Widget
php artisan make:filament-widget GroupBuyStatsOverview --stats-overview
<?php
namespace App\Filament\Widgets;
use App\Models\GroupBuy;
use App\Models\Order;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
class GroupBuyStatsOverview extends BaseWidget
{
protected function getStats(): array
{
return [
Stat::make('總團購數', GroupBuy::count())
->description('所有團購')
->descriptionIcon('heroicon-m-shopping-bag')
->color('primary'),
Stat::make('進行中', GroupBuy::where('status', 'open')->count())
->description('目前開放中的團購')
->descriptionIcon('heroicon-m-arrow-trending-up')
->color('success'),
Stat::make(
'本月營收',
'NT$ ' . number_format(
Order::whereMonth('created_at', now()->month)
->whereYear('created_at', now()->year)
->sum('amount') / 100
)
)
->description('本月訂單總額')
->descriptionIcon('heroicon-m-currency-dollar')
->color('warning'),
];
}
}
三張統計卡片就出現在 Dashboard 頂端——總團購數、進行中的團購、本月營收。不用寫任何前端 JavaScript。
Chart Widget
php artisan make:filament-widget GroupBuyChart --chart
<?php
namespace App\Filament\Widgets;
use App\Models\GroupBuy;
use Filament\Widgets\ChartWidget;
use Illuminate\Support\Carbon;
class GroupBuyChart extends ChartWidget
{
protected static ?string $heading = '每週新增團購';
protected static ?int $sort = 2;
protected function getData(): array
{
$data = collect(range(7, 0))->map(function ($weeksAgo) {
$start = now()->subWeeks($weeksAgo)->startOfWeek();
$end = $start->copy()->endOfWeek();
return [
'week' => $start->format('m/d'),
'count' => GroupBuy::whereBetween('created_at', [$start, $end])->count(),
];
});
return [
'datasets' => [
[
'label' => '新增團購',
'data' => $data->pluck('count')->toArray(),
'borderColor' => '#6366f1',
'backgroundColor' => 'rgba(99, 102, 241, 0.1)',
'fill' => true,
],
],
'labels' => $data->pluck('week')->toArray(),
];
}
protected function getType(): string
{
return 'line'; // 支援 'line', 'bar', 'pie', 'doughnut' 等
}
}
Widget 預設會出現在 Dashboard 頁面。你也可以用 $sort 屬性控制顯示順序——數字越小越上面。
自訂 Widget 範例
除了統計和圖表,你也可以做完全自訂的 Widget,例如顯示「最近即將截止的團購」:
php artisan make:filament-widget ExpiringGroupBuys
<?php
namespace App\Filament\Widgets;
use App\Models\GroupBuy;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Widgets\TableWidget as BaseWidget;
class ExpiringGroupBuys extends BaseWidget
{
protected static ?string $heading = '即將截止的團購';
protected static ?int $sort = 3;
protected int | string | array $columnSpan = 'full';
public function table(Table $table): Table
{
return $table
->query(
GroupBuy::where('status', 'open')
->where('ends_at', '<=', now()->addDays(3))
->where('ends_at', '>', now())
->orderBy('ends_at')
)
->columns([
Tables\Columns\TextColumn::make('title')
->label('標題'),
Tables\Columns\TextColumn::make('organizer.name')
->label('開團主'),
Tables\Columns\TextColumn::make('ends_at')
->label('截止時間')
->dateTime('Y-m-d H:i')
->color('danger'),
Tables\Columns\TextColumn::make('participants_count')
->label('目前人數')
->counts('participants'),
])
->paginated(false);
}
}
這個 Table Widget 直接在 Dashboard 上顯示一個迷你列表,讓管理員一眼掌握哪些團購快截止了。
進階 Eloquent:Eager Loading 解決 N+1
後台蓋好了,現在來處理效能問題。Eloquent 最常見、也最容易踩到的效能地雷就是 N+1 查詢問題。(Eloquent 關聯的基礎用法見第四章)
什麼是 N+1 問題
假設你要顯示「所有團購 + 每個團購的開團主名稱」:
// ❌ 這段程式碼有 N+1 問題
$groupBuys = GroupBuy::all(); // 1 次查詢:SELECT * FROM group_buys
foreach ($groupBuys as $groupBuy) {
echo $groupBuy->organizer->name;
// 每次迴圈都會觸發一次查詢:
// SELECT * FROM users WHERE id = ?
}
如果有 100 筆團購,這段程式碼會執行 101 次查詢(1 次取所有團購 + 100 次取開團主)。這就是 N+1 問題——1 次取清單、N 次取關聯。
你可能覺得 101 次查詢也還好?試試看 1000 筆團購——就是 1001 次查詢。再加上每個團購要顯示參加人數,那就是 2001 次。頁面直接從 200ms 變成 5 秒。
Eager Loading:一次把關聯資料撈好
// ✅ 用 with() 做 Eager Loading
$groupBuys = GroupBuy::with('organizer')->get();
// 只有 2 次查詢:
// SELECT * FROM group_buys
// SELECT * FROM users WHERE id IN (1, 2, 3, ..., 100)
foreach ($groupBuys as $groupBuy) {
echo $groupBuy->organizer->name; // 不會再觸發查詢
}
with('organizer') 會一次把所有需要的 users 撈回來,用 WHERE IN 而不是一筆一筆查。從 101 次查詢變成 2 次。
你可以同時 Eager Load 多個關聯,甚至是巢狀關聯:
// 多個關聯
$groupBuys = GroupBuy::with(['organizer', 'participants', 'products'])->get();
// 巢狀關聯:團購 → 訂單 → 訂單項目
$groupBuys = GroupBuy::with('orders.items')->get();
// 限制 Eager Loading 的欄位(節省記憶體)
$groupBuys = GroupBuy::with('organizer:id,name,email')->get();
withCount:只要數量不要全部資料
有時你只需要知道「有幾個」,不需要把整個關聯載入:
$groupBuys = GroupBuy::withCount('participants')->get();
foreach ($groupBuys as $groupBuy) {
echo $groupBuy->participants_count; // 自動加上 _count 後綴
}
// 只有 1 次查詢(用 subquery 計算 count)
withCount 不會把所有參加者的資料載入記憶體——它只在 SQL 層面用子查詢算出數量。適合用在列表頁只需要顯示數字的場景。若需要一次處理大批量資料,記憶體管理有更多眉角,可參考大量資料的記憶體陷阱。
前後對比
| 情境 | 沒有 Eager Loading | 有 Eager Loading | 改善 |
|---|---|---|---|
| 100 筆團購 + 開團主 | 101 次查詢 | 2 次查詢 | 98% 減少 |
| 100 筆團購 + 開團主 + 參加者 | 201 次查詢 | 3 次查詢 | 98.5% 減少 |
| 100 筆團購 + 參加人數 | 101 次查詢 | 1 次查詢(withCount) | 99% 減少 |
開發模式下禁止 Lazy Loading
為了在開發階段就發現 N+1 問題,可以在 AppServiceProvider 裡設定:
// app/Providers/AppServiceProvider.php
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
// 開發環境:禁止 Lazy Loading,直接拋出例外
Model::preventLazyLoading(! app()->isProduction());
}
加了這行之後,任何沒有預先 Eager Load 的關聯存取都會直接拋出 LazyLoadingViolationException。等於是在開發階段強制你用 with() 載入所有需要的關聯。到了正式環境則自動關閉,避免意外中斷服務。
Query Scopes:可重用的查詢條件
在揪好買裡,你會一直重複寫一些查詢條件:「只要開團中的」、「只要還沒截止的」、「只要某個開團主的」。與其每次都手寫 where() 鏈,不如把它們封裝成 Scope。
Local Scopes
在 Model 裡定義 scope 開頭的方法:
<?php
// app/Models/GroupBuy.php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
class GroupBuy extends Model
{
/**
* 篩選:開團中(status = open 且未截止)
*/
public function scopeOpen(Builder $query): void
{
$query->where('status', 'open')
->where('ends_at', '>', now());
}
/**
* 篩選:已截止(超過截止時間或被手動關閉)
*/
public function scopeExpired(Builder $query): void
{
$query->where(function ($q) {
$q->where('ends_at', '<=', now())
->orWhere('status', 'closed');
});
}
/**
* 篩選:特定開團主的團購
*/
public function scopeByOrganizer(Builder $query, int $userId): void
{
$query->where('organizer_id', $userId);
}
/**
* 篩選:已達成團門檻
*/
public function scopeReachedMinimum(Builder $query): void
{
$query->whereColumn(
'participants_count', '>=', 'min_participants'
);
}
}
使用起來非常乾淨,而且可以鏈式組合:
// 取得所有進行中的團購
$openGroupBuys = GroupBuy::open()->get();
// 某個使用者的進行中團購
$myOpenGroupBuys = GroupBuy::open()
->byOrganizer(auth()->id())
->get();
// 已截止但還沒成團的(可能需要退款)
$expiredNotConfirmed = GroupBuy::expired()
->where('status', '!=', 'confirmed')
->get();
// 搭配 Eager Loading
$openWithDetails = GroupBuy::open()
->with(['organizer', 'products'])
->withCount('participants')
->latest()
->paginate(20);
Scope 的好處是查詢邏輯只定義一次。如果哪天「開團中」的定義改了(例如加了「需要通過審核」這個條件),你只需要改 scopeOpen() 一個地方,所有使用它的地方自動更新。
Global Scopes
Global Scope 會自動套用在所有查詢上,最常見的用途是軟刪除(SoftDeletes trait 就是透過 Global Scope 實現的)。你也可以自訂:
// 自動排除被封鎖的開團主的團購
use Illuminate\Database\Eloquent\Scope;
class ExcludeBannedOrganizersScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$builder->whereHas('organizer', fn ($q) => $q->where('is_banned', false));
}
}
Global Scope 很強大,但也很容易造成「為什麼查不到那筆資料?」的困惑。建議只在非常確定的情境下使用,大多數時候 Local Scope 就夠了。
Subqueries:複雜報表查詢
有些報表需求光靠 with() 和 withCount() 搞不定——例如「每個開團主的累計營收排行」。這時候就需要子查詢。
addSelect + Subquery
use App\Models\User;
use App\Models\Order;
use Illuminate\Database\Eloquent\Builder;
// 每個開團主的累計營收
$organizers = User::query()
->addSelect([
'total_revenue' => Order::query()
->selectRaw('SUM(amount)')
->whereColumn('orders.group_buy_id', 'group_buys.id')
->whereIn('orders.group_buy_id', function ($query) {
$query->select('id')
->from('group_buys')
->whereColumn('group_buys.organizer_id', 'users.id');
}),
])
->addSelect([
'group_buy_count' => GroupBuy::query()
->selectRaw('COUNT(*)')
->whereColumn('group_buys.organizer_id', 'users.id'),
])
->having('group_buy_count', '>', 0)
->orderByDesc('total_revenue')
->get();
這段查詢在 SQL 層面就算好了每個開團主的營收和開團數,不需要在 PHP 端做 N 次迴圈計算。
簡化版:用 withSum
Laravel 其實提供了更簡潔的語法處理常見的聚合需求:
// 每個團購的總訂單金額
$groupBuys = GroupBuy::withSum('orders', 'amount')
->withCount('participants')
->orderByDesc('orders_sum_amount')
->get();
foreach ($groupBuys as $groupBuy) {
echo $groupBuy->orders_sum_amount; // 自動命名:{relation}_sum_{column}
echo $groupBuy->participants_count;
}
withSum()、withAvg()、withMin()、withMax() 這幾個方法底層都是用子查詢實現的,語法上比手寫 addSelect() 簡潔得多。
什麼時候用 Raw SQL
Query Builder 能處理 90% 的需求,但偶爾你會遇到非常複雜的報表查詢——多層 JOIN、窗口函數、CTE(Common Table Expression)。這時候不要硬用 Query Builder,直接寫 Raw SQL 反而更清楚:
use Illuminate\Support\Facades\DB;
// 每週營收趨勢(用窗口函數計算移動平均)
$weeklyRevenue = DB::select("
SELECT
DATE_FORMAT(created_at, '%Y-%u') AS week,
SUM(amount) AS revenue,
AVG(SUM(amount)) OVER (
ORDER BY DATE_FORMAT(created_at, '%Y-%u')
ROWS BETWEEN 3 PRECEDING AND CURRENT ROW
) AS moving_avg
FROM orders
WHERE created_at >= ?
GROUP BY week
ORDER BY week
", [now()->subMonths(3)]);
經驗法則:如果你花超過 10 分鐘在組 Query Builder 鏈,而且結果還不太對,那就直接寫 SQL。 可讀性比「全部用 Eloquent」更重要。Raw SQL 的缺點是失去了資料庫引擎抽象(SQLite 和 MySQL 語法不完全相同),但在報表查詢這種場景,你通常只會在一種資料庫上跑。
Laravel Debugbar:效能偵測利器
前面講了這麼多最佳化技巧,但怎麼確認真的有效?靠感覺不行——你需要實際看到查詢數量和執行時間。Laravel Debugbar 就是幹這個的。
安裝
composer require fruitcake/laravel-debugbar --dev
--dev 很重要——Debugbar 只應該在開發環境使用。安裝後它會自動啟用(當 APP_DEBUG=true 時),在頁面底部出現一條黑色的工具列。
看什麼
Debugbar 提供的資訊非常豐富,但對效能最佳化來說,最重要的是這幾個 tab:
Queries tab(查詢):
- 顯示每個頁面執行了多少次 SQL 查詢
- 每次查詢的完整 SQL 語句
- 每次查詢的執行時間
- 重複查詢會被標記出來——這就是 N+1 問題的最直接證據
Timeline tab(時間軸):
- 整個 request 的生命週期,從 boot 到 response
- 可以看到時間花在哪裡——是 SQL 慢還是 PHP 慢
Memory tab(記憶體):
- 這個 request 使用了多少記憶體
- 如果你用
all()載入了一萬筆資料⋯⋯這裡的數字會告訴你問題有多嚴重
實戰:觀察 N+1 修復效果
以一個灌了 50 筆團購的列表頁為例,修復 N+1 前後的 Debugbar 數據會像這樣:
修復前:
- Queries: 52 queries(1 + 50 筆團購 × 1 取開團主)
- Time: 320ms
- Memory: 14 MB
修復後(加了 with('organizer')):
- Queries: 2 queries
- Time: 45ms
- Memory: 8 MB
差距一目了然。養成習慣:每次寫完一個頁面,開 Debugbar 看一眼查詢數量。超過 10 次的,八成有 N+1 可以修。
提醒: Debugbar 絕對不能部署到正式環境。它會暴露你的 SQL 查詢、環境變數、路由結構——等於是把所有內部資訊攤開給攻擊者看。
--dev裝、APP_DEBUG=false就會自動隱藏,但還是建議在部署前確認一下。
實作:揪好買的開團主與管理員後台
到目前為止我們學了 Filament 和進階查詢的個別技巧,現在把它們串起來,為揪好買建立完整的後台系統。
管理員後台:完整 Resource 體系
管理員需要管理三個核心資源:團購、訂單、使用者。
php artisan make:filament-resource GroupBuy --generate
php artisan make:filament-resource Order --generate
php artisan make:filament-resource User --generate
--generate flag 會根據 Model 的資料表結構自動產生表單和列表欄位——先自動生成,再手動微調,比從空白開始快得多。
管理面板的設定在 AdminPanelProvider:
<?php
// app/Providers/Filament/AdminPanelProvider.php
namespace App\Providers\Filament;
use Filament\Panel;
use Filament\PanelProvider;
use Filament\Support\Colors\Color;
use App\Filament\Widgets\GroupBuyStatsOverview;
use App\Filament\Widgets\GroupBuyChart;
use App\Filament\Widgets\ExpiringGroupBuys;
class AdminPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
return $panel
->default()
->id('admin')
->path('admin')
->login()
->colors([
'primary' => Color::Indigo,
])
->discoverResources(
in: app_path('Filament/Resources'),
for: 'App\\Filament\\Resources'
)
->discoverPages(
in: app_path('Filament/Pages'),
for: 'App\\Filament\\Pages'
)
->widgets([
GroupBuyStatsOverview::class,
GroupBuyChart::class,
ExpiringGroupBuys::class,
])
->middleware([
// ...預設 middleware
])
->authMiddleware([
// 確保只有管理員能存取
]);
}
}
開團主儀表板:第二個 Panel
揪好買的開團主不是管理員,但他們需要看到自己的開團統計、管理自己的團購。Filament 5 支援多 Panel——你可以為不同角色建立不同的後台。
php artisan make:filament-panel organizer
這會建立 app/Providers/Filament/OrganizerPanelProvider.php:
<?php
namespace App\Providers\Filament;
use App\Models\User;
use Filament\Panel;
use Filament\PanelProvider;
use Filament\Support\Colors\Color;
class OrganizerPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
return $panel
->id('organizer')
->path('organizer') // 路由前綴:/organizer
->login()
->colors([
'primary' => Color::Emerald, // 用不同主色區分
])
->discoverResources(
in: app_path('Filament/Organizer/Resources'),
for: 'App\\Filament\\Organizer\\Resources'
)
->discoverPages(
in: app_path('Filament/Organizer/Pages'),
for: 'App\\Filament\\Organizer\\Pages'
);
}
}
接著為開團主建立專屬的 Resource,只顯示自己的資料:
<?php
// app/Filament/Organizer/Resources/MyGroupBuyResource.php
namespace App\Filament\Organizer\Resources;
use App\Models\GroupBuy;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class MyGroupBuyResource extends Resource
{
protected static ?string $model = GroupBuy::class;
protected static ?string $navigationLabel = '我的團購';
protected static ?string $modelLabel = '團購';
// 關鍵:只查自己的團購
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->byOrganizer(auth()->id()) // 用前面定義的 scope
->withCount('participants')
->withSum('orders', 'amount');
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('title')
->label('標題')
->searchable(),
Tables\Columns\TextColumn::make('status')
->label('狀態')
->badge()
->color(fn (string $state): string => match ($state) {
'draft' => 'gray',
'open' => 'success',
'closed' => 'warning',
'confirmed' => 'primary',
default => 'gray',
}),
Tables\Columns\TextColumn::make('participants_count')
->label('參加人數')
->sortable(),
Tables\Columns\TextColumn::make('orders_sum_amount')
->label('訂單總額')
->money('TWD')
->sortable(),
Tables\Columns\TextColumn::make('ends_at')
->label('截止時間')
->dateTime('Y-m-d H:i'),
])
->defaultSort('created_at', 'desc');
}
// ...form() 和其他設定
}
注意 getEloquentQuery() 的覆寫——這是 Filament 的資料隔離機制。開團主只看得到自己的團購,就算手動改 URL 中的 ID 也拿不到別人的資料。搭配前面定義的 scopeByOrganizer(),query 條件清楚又好維護。
自訂 Dashboard 頁面
開團主的 Dashboard 跟管理員不同——他們更關心自己的數據。你可以建立自訂頁面:
php artisan make:filament-page OrganizerDashboard --panel=organizer
<?php
namespace App\Filament\Organizer\Pages;
use App\Models\GroupBuy;
use Filament\Pages\Page;
class OrganizerDashboard extends Page
{
protected static ?string $navigationIcon = 'heroicon-o-home';
protected static ?string $title = '我的儀表板';
protected static string $view = 'filament.organizer.pages.organizer-dashboard';
protected static ?int $navigationSort = -2;
public function getViewData(): array
{
$userId = auth()->id();
return [
'totalGroupBuys' => GroupBuy::byOrganizer($userId)->count(),
'activeGroupBuys' => GroupBuy::byOrganizer($userId)->open()->count(),
'totalRevenue' => GroupBuy::byOrganizer($userId)
->withSum('orders', 'amount')
->get()
->sum('orders_sum_amount'),
'recentGroupBuys' => GroupBuy::byOrganizer($userId)
->with('participants')
->withCount('participants')
->latest()
->take(5)
->get(),
];
}
}
看到了嗎?byOrganizer()、open()、withCount()、withSum()——這一章學的所有技巧全部用上了。Query Scope 讓查詢條件可讀又可重用,Eager Loading 確保不會有 N+1 問題,Subquery 聚合讓報表數據在 SQL 層面就算好。
小結:後台不用從零寫起
這一章我們同時搞定了兩件事:用 Filament 5 快速建立後台介面,以及用 Eloquent 進階查詢確保後台跑得快。
回顧一下重點:
- Filament 5 是 Laravel 生態最成熟的開源後台方案——Resource 自動產生 CRUD、Widget 做 Dashboard、多 Panel 支援不同角色
- Eager Loading(
with())解決 N+1 問題,withCount()和withSum()處理聚合需求 - Query Scopes 把查詢條件封裝成可重用的方法,
scopeOpen()、scopeByOrganizer()讓程式碼簡潔又好維護 - Subqueries 處理複雜報表需求,知道什麼時候該用 Raw SQL 也是一種能力
- Debugbar 是開發階段的效能照妖鏡,養成每個頁面都看一眼查詢數量的習慣
preventLazyLoading()在開發環境強制杜絕 N+1,等於是編譯時期就抓到效能問題
後台和效能是同一件事的兩面——有了漂亮的管理介面,但背後查詢跑得慢,管理員一樣會抱怨。反過來說,查詢最佳化做得再好,沒有 UI 也沒人看得到。兩條線必須同時顧。
下一章我們進入測試。寫到現在,揪好買已經有使用者系統、團購邏輯、付款流程、通知系統、後台管理⋯⋯功能越多,改壞東西的風險就越高。第十三章「測試不是選配:用 Pest 寫出有信心的 Laravel 程式」會教你用 Pest 為核心流程寫完整測試,搭配 GitHub Actions 讓每次 Push 都自動驗證——從此你可以安心重構,不怕改一處壞三處。