跳至主要內容
技術

後台管理與進階查詢:用 Filament 打造管理介面

後台管理與進階查詢:用 Filament 打造管理介面
PHP/Laravel 完全指南 第 12 / 15 篇

本篇是「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 5Nova 5Backpack v7
授權MIT(完全免費)付費(Solo $99/site、Pro $199/site)免費核心 + 付費 PRO
技術棧Livewire + TailwindVue 3 + InertiaBlade + 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

這條指令會做幾件事:

  1. 建立 app/Providers/Filament/AdminPanelProvider.php——管理面板的核心設定
  2. 發布相關的 assets 和 config
  3. 設定 /admin 路由

建立管理員帳號

php artisan make:filament-user

終端會問你姓名、Email、密碼。填完之後,打開瀏覽器連到 http://localhost:8000/admin,登入就能看到空白的管理面板。

注意: Filament 預設使用 users table 的帳號,但只有被標記為 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 Loadingwith())解決 N+1 問題,withCount()withSum() 處理聚合需求
  • Query Scopes 把查詢條件封裝成可重用的方法,scopeOpen()scopeByOrganizer() 讓程式碼簡潔又好維護
  • Subqueries 處理複雜報表需求,知道什麼時候該用 Raw SQL 也是一種能力
  • Debugbar 是開發階段的效能照妖鏡,養成每個頁面都看一眼查詢數量的習慣
  • preventLazyLoading() 在開發環境強制杜絕 N+1,等於是編譯時期就抓到效能問題

後台和效能是同一件事的兩面——有了漂亮的管理介面,但背後查詢跑得慢,管理員一樣會抱怨。反過來說,查詢最佳化做得再好,沒有 UI 也沒人看得到。兩條線必須同時顧。

下一章我們進入測試。寫到現在,揪好買已經有使用者系統、團購邏輯、付款流程、通知系統、後台管理⋯⋯功能越多,改壞東西的風險就越高。第十三章「測試不是選配:用 Pest 寫出有信心的 Laravel 程式」會教你用 Pest 為核心流程寫完整測試,搭配 GitHub Actions 讓每次 Push 都自動驗證——從此你可以安心重構,不怕改一處壞三處。

留言討論

esc
輸入關鍵字搜尋文章...
查看收藏 →